Skip to main content

TCP Sockets in C: socket, bind, listen, accept, connect, send, recv

What This Concept Is​

The BSD socket API is how portable UNIX programs speak TCP and UDP. The server and client sequences are small and worth memorizing.

Server:

socket() -> bind() -> listen() -> accept() -> recv()/send() -> close()

Client:

socket() -> connect() -> send()/recv() -> close()

Key calls:

  • int socket(int domain, int type, int protocol) -- e.g., AF_INET, SOCK_STREAM, 0. Returns an fd.
  • int bind(int fd, const struct sockaddr *addr, socklen_t n) -- attach the socket to a local IP and port.
  • int listen(int fd, int backlog) -- mark the socket as passive; queue incoming connections.
  • int accept(int fd, struct sockaddr *peer, socklen_t *n) -- block until a client connects; return a new fd for that connection. The original fd stays open to accept more.
  • int connect(int fd, const struct sockaddr *addr, socklen_t n) -- actively open a connection.
  • ssize_t send(int fd, const void *buf, size_t n, int flags) -- like write, plus TCP-specific flags.
  • ssize_t recv(int fd, void *buf, size_t n, int flags) -- like read, plus flags.

TCP sockets are just fds -- once connected, read/write work, so do poll, select, epoll.

Why It Matters Here​

Almost every system exposed to the network is, at some layer, making these calls. Web servers, databases, IPC brokers, REST clients -- they all accept or connect. Modern frameworks wrap the API, but the wrappers leak: when your connection hangs, your read returns short, or EADDRINUSE keeps you from restarting, you are debugging at this layer.

Concrete Example​

A minimal TCP echo server and client (IPv4, single-threaded, one connection at a time):

/* server.c */
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>

int main(void) {
int s = socket(AF_INET, SOCK_STREAM, 0);
if (s < 0) { perror("socket"); exit(1); }

int yes = 1;
setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes);

struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(9000);

if (bind(s, (struct sockaddr*)&addr, sizeof addr) < 0) { perror("bind"); exit(1); }
if (listen(s, 16) < 0) { perror("listen"); exit(1); }

for (;;) {
int c = accept(s, NULL, NULL);
if (c < 0) { perror("accept"); continue; }
char buf[4096];
ssize_t n;
while ((n = recv(c, buf, sizeof buf, 0)) > 0) {
ssize_t off = 0;
while (off < n) {
ssize_t w = send(c, buf + off, n - off, 0);
if (w < 0) break;
off += w;
}
}
close(c);
}
}
/* client.c */
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>

int main(void) {
int s = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in a = {0};
a.sin_family = AF_INET;
a.sin_port = htons(9000);
inet_pton(AF_INET, "127.0.0.1", &a.sin_addr);
connect(s, (struct sockaddr*)&a, sizeof a);

const char *msg = "hello, socket\n";
send(s, msg, strlen(msg), 0);

char buf[128];
ssize_t n = recv(s, buf, sizeof buf - 1, 0);
if (n > 0) { buf[n] = 0; write(1, buf, n); }
close(s);
}

Two things worth internalizing. SO_REUSEADDR avoids "Address already in use" when you restart the server while an old connection is in TIME_WAIT. The send loop is there because send, like write, can return fewer bytes than requested -- especially under load.

Common Confusion / Misconception​

"recv returns when my message arrives." No. TCP is a byte stream. There is no "message." recv returns whenever some bytes are available -- possibly half of your logical message, possibly the tail of one message and the head of the next. Framing (length prefixes, delimiters, HTTP Content-Length) is your job.

"I need a new socket for every connection." The server needs one listening socket (the result of socket/bind/listen). accept returns a different fd per client -- that is the per-connection socket. Closing the connection fd does not close the listener.

"htons and htonl are optional on my little-endian machine." They are mandatory. Network byte order is big-endian by specification. Skipping them means your port and address will be interpreted upside-down on other hosts, and sometimes even locally depending on the kernel path.

Another trap: not handling EINTR on blocking accept/recv/send, or not setting SO_REUSEADDR and discovering that your CI cannot restart the server between tests.

How To Use It​

For any TCP program:

  1. Decide framing before writing a byte. Length prefix, line-delimited, or HTTP-style headers.
  2. Always loop send and recv. Treat them like write and read.
  3. Always close both ends on shutdown; use shutdown(fd, SHUT_WR) if you want to signal end-of-input while still reading responses.
  4. On the server, use SO_REUSEADDR and a sensible listen(backlog) (16-128 is typical).
  5. For more than one connection at a time, either fork per connection, spawn a thread per connection, or use poll/epoll/kqueue for event-driven I/O.

Check Yourself​

  1. Why does accept return a new fd instead of reusing the listening one?
  2. Why must send and recv always be looped?
  3. What does SO_REUSEADDR actually allow the kernel to do?

Mini Drill or Application​

Do all four:

  1. Compile and run the echo server and client above. Connect with nc 127.0.0.1 9000 as a second client.
  2. Make the server fork per connection so it can serve multiple clients simultaneously. Reap children with a SIGCHLD handler.
  3. Extend the client to send a 1 MB payload and verify every byte comes back identical. Expect multiple recv calls.
  4. Write a minimal HTTP server: accept, read the request line, respond with HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok. Test with curl.

Read This Only If Stuck​