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