Spectranet: Tutorial 3

From Spectrum
Jump to navigation Jump to search

As you go into this second practical example - one thing should stand out - there's not all that much difference between writing a simple server and a client! You open the socket the same way, you close it the same way, and you read and write to it the same way. The differences are that you may convert a hostname (such as 'spectrum.alioth.net') into an IP address (or a dotted decimal string into the system representation of an IP address), and instead of using bind, listen and accept, you just call connect to make the connection to the remote host.

So, this is generally what a TCP client must do:

  • Look up the remote host using gethostbyname.
  • Open a socket, using the socket routine.
  • Connect to the IP address, using the connect routine.
  • Send and receive data, using send and recv.
  • Close the socket using close (or sockclose, when using the z88dk's C compiler).

In this example, we'll connect to the host 'spectrum.alioth.net' on port 80, and send some data that will hopefully elicit a reply. Port 80 is the port for web traffic, so the data we'll send will be 'GET / HTTP/1.0\r\n\r\n' (\r\n being the carriage return/newline sequence).

The code

The code snippets are both in C and assembler. It's worth opening them in another browser window or tab so you can see the functions in context: the assembly example is here [1], and the C example is here [2]

f you're using assembly language, your code should include the file spectranet.asm, available here: [3] . This file has all the symbols you need to call the ROM code. C users, using the Z88DK should include the relevant includes - for clients, this will be sys/socket.h, sys/types.h and netdb.h, and link with the library libsocket.

If you jumped straight in here, rather than starting with Tutorial 2, you'll want to read the bit about HLCALL and IXCALL, which explains how the ROM routines are invoked, in Tutorial 2.

Looking up the remote host

The first thing your client will normally need to do is turn a name such as 'spectrum.alioth.net', or perhaps a dotted decimal IP address string such as '70.85.70.131' into a 4-byte big endian representation of the IP address. The routine that does this is called gethostbyname, and for C users, it's defined in <netdb.h>. It's best to look up the host before opening sockets when using the Spectranet because it's a small system, and you can only open up to four sockets at once. The gethostbyname function opens a socket to talk to your nearest DNS server, and then closes it once it's got a response. If your client needs to open four sockets at once to remote hosts, if you try to do the lookup after opening the sockets, you'll find that the lookup fails. So it's good practise to get the gethostbyname call out of the way before creating the socket.

At this stage, something should be pointed out about any strings you might pass to the Spectranet ROM.

The Spectranet ROM routines all handle C strings. If you're a C programmer you'll immediately know what this implies - but if you're not, a C string is a set of characters that is null terminated - i.e. the string is ended by a byte set to zero. So, for instance, the string "Hello world" is represented as "Hello world" with the byte after the 'd' in world being set to zero. With sjasmplus, you can define C strings very easily, as follows:

remote_host    defb "spectrum.alioth.net",0

The ,0 puts the null terminator on the end. Other assemblers may accept null terminated strings in this way, too.

In assembler you use gethostbyname as follows:

     ld hl, remote_host      ; address of the string containing the hostname
     ld de, ip_buffer        ; pointer to 4 bytes of memory to hold the IP address
     ld ix, GETHOSTBYNAME    ; call the gethostbyname function
     call IXCALL
     jr c, .error            ; If carry was set, there was an error.

As in the functions covered in tutorial 2, gethostbyname will return with the carry flag reset when it succeeds. If it fails, it returns with carry set, and the A register contains the error code. The HL register pair points to the string containing the hostname we want to look up, or the dotted decimal representation of the IP address. If you pass a dotted decimal string, gethostbyname doesn't actually do any lookup - it just converts it to the system representation, a 4 byte big endian value. The register pair DE should contain the address of a 4 byte buffer for this value.

The C example follows the BSD socket library in form, and it's used like this:

     struct hostent *he;
     ...
     he=gethostbyname("spectrum.alioth.net");
     if(!he)
     {
         printk("Could not look up host!\n");
         return;
     }

On success, the C gethostbyname() call returns a pointer to a hostent structure, containing the address. Note that the pointer returned is to some statically allocated storage - so if you're going to make multiple calls to gethosbyname, be sure to copy the values you want to keep! This is true of gethostbyname() on Unix and Windows systems too.

If the C gethostbyname() call fails to find a host, it returns a null pointer.

Using the data returned by gethostbyname() will be covered below, as we cover connect.

Open a socket

Just as in the server example, a socket needs to be opened. The operation is exactly the same as for a server - but here's the code snippet again - in assembler:

    ld c, SOCK_STREAM     ; The kind of socket we want - a stream - i.e. TCP
    ld hl, SOCKET         ; The address of the socket() routine
    call HLCALL           ; Call it
    jr c, .error          ; Handle any errors
    ld (v_sockfd), a      ; Save the socket handle somewhere in memory

And in C:

    int sockfd;
    sockfd=socket(AF_INET, SOCK_STREAM, 0);

Just as in the server example, all this has done is allocate the resources associated with the socket - the socket can't yet be used for communication. For that, we need to use connect.

Connect to the remote host

The assembly version of connect takes three parameters - as you may expect by now, the socket file descriptor that was returned by the socket call, the IP address to connect to and the port number to connect to. All connections to a remote host need both an address and port. In this example, we'll be connecting to whatever address gethostbyname resolved for the string "spectrum.alioth.net", and port 80 - to connect to the HTTP server at that address.

Connect can fail for several reasons. The most common are:

  • Connection refused - this is generally because nothing was listening on that port.
  • Timeout - The remote host didn't respond within a timely period - it may be down, or the port may be blocked by a firewall, or a myriad of other reasons.

If connect returns successfully, you now have a TCP stream established to the remote host, and you can send and receive data on the socket - it's ready for use. Connect is the only call you need to make a socket useful for a client program.

In assembler, you call connect like this:

     ld a, (v_sockfd)         ; get the socket file descriptor into the A register
     ld de, ip_buffer         ; The 4 byte big endian IP address that gethostbyname returned
     ld bc, 80                ; port 80
     ld hl, CONNECT           ; and call connect
     call HLCALL
     jr c, .error

The arguments passed are the socket's file descriptor as returned earlier by the socket call, in A. DE contains the memory address of where the IP address is stored. The BC register pair is set to the port that we want to connect to. If connect fails, the carry flag is set and A contains the error code.

If you're using C, you use connect() as follows:

     struct sockaddr_in remoteaddr;
     ....
     remoteaddr.sin_port=htons(80);            /* connect to port 80 */
     remoteaddr.sin_addr.s_addr=he->h_addr;    /* IP address returned by gethostbyname */
     if(connect(sockfd, &remoteaddr, sizeof(sockaddr_in)) < 0)
     {
         sockclose(sockfd);                    /* close the socket */
         printk("Connect failed!\n");
         return;
     }

As you can see, connect takes a sockaddr_in structure - i.e. an internet address structure. Two members need to be set - the port, and the IP address. The htons() macro you see is actually a no-op, but it's good practise to include it (it won't bloat your code, it's just a macro). The htons() macro is used to convert a 16 bit number from machine byte order (little endian) into network byte order (big endian), but the underlying ROM code actually does this. The IP address is stored in the struct hostent's h_addr member. If connect() fails, it returns a negative value. Don't forget to close the socket when cleaning up from an error!

Sending and receiving data

This is exactly the same as in the server example, but to recap - the send and recv routines are used. In assembler, here's the snippet from the example simple client program:

     ; Now send some data - GET / HTTP/1.0 which will elicit a response.
     ld a, (v_sockfd)          ; Get the socket file descriptor
     ld de, STR_send           ; String to send
     ld bc, 18                 ; which is 18 bytes long
     ld hl, SEND               ; to be passed to the ROM SEND routine
     call HLCALL
     jr c, ERROR
     .....
     ld a, (v_sockfd)
     ld de, resp_buffer        ; the buffer we want to fill.
     ld bc, 1024               ; up to 1K at a time
     ld hl, RECV               ; and use the ROM's RECV routine
     call HLCALL
     jr c, ERROR

For brevity, both send and recv are shown in use here. Our simple client example sends the string 'GET / HTTP/1.0\r\n\r\n' then immediately calls recv to wait for the result, returning up to 1024 bytes of the response.

The C code to do the same looks like this:

     /* Send 'GET / HTTP/1.0\r\n\r\n' to the remote host */
     bytes=send(sockfd, txdata, strlen(txdata), 0);
     if(bytes < 0)
     {
        printk("Send failed\n");
        sockclose(sockfd);
        return;
     }

     /* Get the response - use 1 byte less than the buffer so
        we can guarantee to be able to null terminate it for printing */
     bytes=recv(sockfd, rxdata, sizeof(rxdata)-1, 0);
     if(bytes < 0)
     {
        printk("recv failed\n");
        sockclose(sockfd);
        return;
     }

If you compare this with the server code, you'll notice it's really no different - one a TCP socket is open, it works the same way whether you are the server or the client.

Close the socket

Just as in the server example, you need to close the socket when you're done with it, or before your program exits - otherwise it won't get closed until the machine is reset. To recap, in assembler:

     ld a, (v_sockfd)         ; Get the socket fd
     ld hl, CLOSE             ; and close it.
     call HLCALL

And in C:

     sockclose(sockfd);

In summary

Client communication is very simple - you need to look up the IP address of the remote host you want to connect to with gethostbyname, open a socket, and use connect to make contact with the remote host. Data is then sent in the normal way, using send and recv. Once the socket is no longer needed, it's closed with close (or sockclose, if you are using C).

Next, we'll take a look at UDP.

Spectranet: Tutorial 4 - Datagram communication.

Further reading

The reference manual for the functions used by this tutorial: