首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >揭秘互联网的“第一道门”:初识 Socket 编程,亲手推开网络通信的奇妙世界

揭秘互联网的“第一道门”:初识 Socket 编程,亲手推开网络通信的奇妙世界

作者头像
海棠蚀omo
发布2026-01-12 17:01:43
发布2026-01-12 17:01:43
1070
举报

前言: 在上一篇文章中,我们沿着网络发展的长河,俯瞰了宏观的网络架构。然而,“纸上得来终觉浅”,掌握了地图并不等于真正开启了航行。今天,我们将正式踏入网络编程的实战疆域。作为开发者,我们要推开的第一扇大门,便是连接应用与网络的灵魂之桥——Socket 编程。 接下来,请随我一起拨开云雾,深入探寻 Socket 的奥秘,听我慢慢道来。

一.理解源IP地址和目的IP地址

经过我们上一篇的讲解,我们知道IP地址在网络中,用来标识主机的唯一性,那么下面我们来思考一个问题:数据传输到主机是目的吗?

答案肯定不是的,我们将数据传输到主机是为了让人去使用这些数据,也就是数据是给人用的,这才是我们的目的,比如:我们聊天是人在聊天,下载东西也是人要下载,浏览网页也是人在浏览。

那么此时就引出一个问题:人是怎么看到聊天信息的呢?是怎么执行下载任务的呢?是怎么浏览网页的呢?

是不是通过微信,百度网盘,浏览器这些电脑中的进程才能够执行的啊,所以我们换句话说就是进程就是人在系统中的代表,把数据交给了进程,就相当于人拿到了数据。

所以:数据传输到主机并不是目的,而是手段,到达主机内部,再交给主机内的进程,才是目的。

而有了上面的认识后,我们就可以输出一个结论:网络通信的本质就是进程间通信!!!

但是,我们来看:

通过IP地址我们可以锁定一台主机,但是我们该如何锁定另一台主机中的目标进程呢?

这就与下面我们要讲的端口号有关,下面我们来看。

二.认识端口号

端口号(port)是传输层协议的内容:

1.端口号是一个2字节16位的整数 2.端口号来表示一个进程,告诉操作系统,当前的这个数据要交给哪一个进程来处理 3.IP地址 + 端口号就能够识别网络上某一台主机的某一个进程 4.一个端口号只能被一个进程占用

那么对于这个端口号想必大家心中都有一些疑问,下面我用两个问题来总结:

1.标识一台主机中进程的方式有很多,比如:进程的pid,为什么在设计出一种端口号呢?

2.上面说一个端口号只能被一个进程占用,那么反过来一个进程只能有一个端口号吗?

第一个问题: 我们将这个场景更换到我们的日常生活中,那么这个问题就可以变为:我们可以用身份证来标识一个人,为什么学校还要用学号来标识一个学生呢?为什么公司中还要用工号来标识一个员工呢? 如果我们不再使用学号,工号这些东西,那么如果有一天社会上不在使用身份证来标识一个人,那么是不是学校和公司的相关系统就得重新设计,重来一遍? 那么这种方式相关度或者耦合度就太高了,所以我们设计端口号的根本原因就是为了降低耦合度,让不同的板块之间联系不那么紧密,这样一个板块出问题,不影响另一个板块。 第二个问题: 答案并不是,其实一个进程时可能会有多个端口号的,一个端口号确实只能绑定一个进程,一个进程有多个端口号,那么这些端口号就都指向同一个进程。

在解决了上面的疑问后,想必我们大家现在都很好奇这个端口号到底长什么样呢?那么下面我们就来看看。

2.1端口号范围划分

1.0 - 1023:这个范围的端口号都是知名端口号,像HTTP,FTP,SSH等这些广为使用的应用层协议,它们的端口号都是固定的。 2.1024 - 65535:这个范围的端口号就是操作系统动态分配的端口号,客户端程序的端口号,就是由操作系统从这个范围分配的。

端口号的这种特性和我们日常生活中的手机号码是很相似的,上面说0 - 1023这个范围的端口号是知名端口号,就和我们拨打的110,119,120等这些号码是一样的,我们打110就知道是打给警察的,119就是火警电话,120就是打给医院的,一样都是知名号码。

而剩下的范围就是我们每个人的手机号码了,每个人的手机号码都不一样。

2.2理解源端口号和目的端口号

在传输层协议(TCP和UDP)中,的数据段中是有两个端口号的,分别叫做源端口号和目的端口号。

这其实很容易理解,其实就是在描述" 数据是谁发的,要发给谁 ",我们不只要知道自己主机中进程端口号,还要知道数据是谁发过来的,也便于我们未来将数据发给对方。

2.3理解socket

通过对上面知识的理解,我们现在就知道了,IP地址用来表示互联网中唯一的一台主机,port端口号用来表示该主机上唯一的一个网络进程。

那么也就是:IP + port就能标识互联网中的任何一个进程,所以,通信的时候,本质是两个互联网进程代表人来进行通信,{srcIp,srcPort,dstlp,detPort}这样的4元组就能表示互联网中唯二的两个进程!!!

而我们将ip + port的组合叫做套接字socket,我们的标题说的就是这个socket,而我们后面要讲的也是socket编程。

三.传输层的典型代表

我们前面了解过了Linux系统的知识,也了解了网络协议栈,我们清楚传输层是属于内核的,那么我们要通过网络协议栈来进行网络通信,那么必然调用的就是传输层的系统调用,因为我们所使用的应用层下面紧挨着的就是传输层。

那么下面我们就不得不来了解一下传输层的两种协议:TCP协议和UDP协议。

TCP协议: TCP全称叫做:Transmission Control Protocol(传输控制协议),它有以下特性: 1.传输层协议 2.有连接 3.可靠传输 4.面向字节流

UDP协议: UCP全称叫做:User Datagram Protocol(用户数据报协议),它有以下特性: 1.传输层协议 2.无连接 3.不可靠传输 4.面向数据报

因为我们还有真正来讲解这两种协议,这里我们对其有一个直观的认识即可,不过我们先对其中的一些特性先跟大家渗透一下,这样后面再讲时就能更好地接受。

3.1有连接和无连接

我们先说TCP协议的有连接: 这里用生活中的一个场景来解释:我们平常在接听电话时,对方一般都会先说:" 喂 ",我们一般也会说:" 喂 ",这种做法就像先和对方建立起连接一样,确保对方在接听电话,这种现象映射到计算机中叫做:握手,而有连接这种特性就是对握手状态的维护。 因为我们们还没有详细讲解TCP协议,所以对于握手这种知识我们这里有个概念即可,后面的章节中会细讲。

下面我们再来看UDP协议的无连接: 这里依旧是拿我们生活中的例子来说明:在工作中,你的上司或者你的老板如果想让你做某些事,他需要给你发邮件,那么他会像上面打电话一样给你发一个邮件先确认你是否在吗? 很明显不会的,他直接就会在邮件中表明他需要你干什么事情,也就是他不需要跟你建立起连接,他只需要将邮件发给你就行,这就是UDP协议的无连接特性。 和上面一样,对于这些特性的细节我们在后面的章节中在讲解,这里了解一下即可。

3.2可靠传输和不可靠传输

我们日常生活中经常听到像这样:" 谁谁谁人很可靠,谁谁谁不行,一点也不可靠 "等类似的话语,但是我想说上面的可靠与不可靠和我们将要讲的TCP,UDP协议的特性并不是一个东西。

上面的可靠传输和不可靠传输只是这两种协议的特性而已,下面我就来带大家了解一下这两种特性:

可靠传输: 当我们发送数据时可能会遇到:数据中途丢失了,数据传输速度过快了,数据发重了,数据中途堵塞了等等,可能会遇到各种问题,但是如果是可靠传输,就会自动来纠正这些问题,比如:重新发送数据,换个路线接着发送等多种解决方式来保证数据完整和成功传输。 不可靠传输: 与上面的可靠传输正好相反,当我们发送的数据遇到上面的那些问题时,因为是不可靠传输,所以不会去解决这种问题,也就是我只管将数据发出去,中途遇到什么问题导致你没收到,我一概不care。

但是也不要就简单的认为可靠传输能解决数据传输过程中的问题就感觉很好,那你有没有思考一个问题:如果要想通过可靠传输来解决这些问题,那么前提是我们得先知道出现这些问题了吧,那么是怎么知道的呢?

是不是还得花功夫去查是否出现问题了,那么成本是不是就提高了,所以我们不要只看到可靠传输的优点,还要知道它在做到这些事情的同时成本也相应地提高了,也就是它也有缺点。

而不可靠传输虽然没有这些功能,但是相应的我们也不需要去查找问题,成本自然也就没那么高,计算机中的每种特性都有利有弊。

而剩下的两种特性面向字节流和面向数据报,这两个相比较上面不太好理解,所以这里就不讲解了,我们在真正去讲解这些协议的时候再详细说明。

四.网络字节序

在学习C语言的时候我们就知道,内存中的多字节数据相对于内存地址有大小端之分,也就是大小端存储,而磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分,那么我们如果直接通过网络来进行数据传输,会发生什么呢?我们来看看:

如上图所示,主机1采用大端存储,而主机2采用小端存储,而网络数据流的地址这样规定:先发出的数据是低地址,后发出的数据是高地址,那么就会发生下面的现象:

主机1明明想要发的数据是:0x1234abcd,但是传过去后,因为主机2是小端存储,所以主机2读出来的数据就是:0xcdab3412,正好相反,所以为了避免这样的问题,TCP/IP协议规定:网络数据流应采用大端字节序,即低地址高字节。

也就是不管这台主机是大端机还是小端机,都要按照规定的网络字节序来发送和接收数据,就比如当前的主机是小端机,那么它在发送数据时就需要先将数据转成大端,否则就忽略,直接发送即可。

那么为了支持这种转化功能,我们可以调用下面这些库函数做网络字节序和主机字节序的转换:

上面的两个函数是在发送数据时将数据转化成大端,后面两个函数是在接收数据时将数据转化为主机字节序,也就是如果是主机本身就是大端,那就什么也不会做,如果是小端,则会将大端数据转化为小端数据。

五.socket编程接口

在铺垫了上面的知识后,下面我们就要进入socket编程的部分了,下面我们先来看看我们会面会用到哪些接口:

代码语言:javascript
复制
// 创建 socket ⽂件描述符 (TCP/UDP, 客⼾端 + 服务器)
int socket(int domain, int type, int protocol);

// 绑定端⼝号 (TCP/UDP, 服务器)
int bind(int socket, const struct sockaddr *address, socklen_t address_len);

// 开始监听socket (TCP, 服务器)
int listen(int socket, int backlog);

// 接收请求 (TCP, 服务器)
int accept(int socket, struct sockaddr* address, socklen_t* address_len);

// 建⽴连接 (TCP, 客⼾端)
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

这些接口我们在下面使用时在详细介绍,我们先介绍一些前置知识:sockaddr结构。

我们要知道,网络通信也是有不同场景的,就如:

1.unix域间socket:即UNIX域套接字,是专门在一台主机的不同进程间进行通信的的场景所提供的解决方案。 2.网络socket:这种场景就是我们上面一直在说的两台主机通过网络进行通信,解决方式就是我们上面说的ip + port,也是套接字。 3.原始socket:这种场景是可以允许应用程序直接访问底层协议栈,不需要经过传输层的处理,而实现这种方式的就是Row Socket,也就是原始套接字。

那么既然有这些不同的场景,原则上我们就要针对不同的场景设计出不同的socket接口来进行操作,但是我们并没有这样做,而是用上面的这一套接口完成所有的功能需要!!!

我们下面来看这张图:

这个struct sockaddr只是一个通用的API模板,我们实际在使用时要传的是后面的两个结构体,分别是:struct sockaddr_in和struct sockaddr_un,并且要进行强转。

我们要进行网络通信时,如:网络socket,原始socket,就要用struct sockaddr_in结构体,而要进行本地通信,也就是进程间通信我们就要使用struct sockaddr_un结构体。

是否感觉上面的这种方式很熟悉,我们将其化成下面这样相信大家瞬间就能恍然大悟了:

没错,就是多态,这里体现的就是多态的思想,不过这个时期还没有C++,所以这里的实现方式略显粗糙,我们可以认为这就是多态的雏形。

我们将其看成多态其实就好理解很多了,它们的结构是很相似的。

六.Socket编程实现Echo server

只有上面的理论知识不足以让我们有更深的理解,下面我们实现一个简单的基于UDP协议的回显服务器和客户端代码来帮助大家熟练使用Socket编程的各种接口。

6.1准备工作

首先我们要先创建这几个文件,后面三个文件代表的就是客户端和服务端,因为我们主要实现的就是服务端,所以这里我又创建了一个.hpp文件,用来实现服务端的各种功能函数。

而这里面还有两个文件,分别是logger.hpp和Mutexhpp,logger.hpp这是我实现的一个打印日志功能的文件,而Mutex.hpp我们之前见过,就是自己封装实现的互斥锁,因为logger.hpp中用到了互斥锁,所以我就加上了。

创建好文件后,我们下面就来先做一些准备工作:

代码语言:javascript
复制
#pragma once

#include <iostream>
#include <string>
#include <filesystem> // C++17 文件操作
#include <fstream>
#include <ctime>
#include <unistd.h>
#include <memory>
#include <sstream>
#include "Mutex.hpp"

// 规定出场景的日志等级
enum class LogLevel
{
    DEBUG,
    INFO,
    WARNING,
    ERROR,
    FATAL
};

std::string Level2String(LogLevel level)
{
    switch (level)
    {
    case LogLevel::DEBUG:
        return "Debug";
    case LogLevel::INFO:
        return "Info";
    case LogLevel::WARNING:
        return "Warning";
    case LogLevel::ERROR:
        return "Error";
    case LogLevel::FATAL:
        return "Fatal";
    default:
        return "Unknown";
    }
}

// 20XX-08-04 12:27:03
std::string GetCurrentTime()
{
    // 1. 获取时间戳
    time_t currtime = time(nullptr);

    // 2. 如何把时间戳转换成为20XX-08-04 12:27:03
    struct tm currtm;
    localtime_r(&currtime, &currtm);

    // 3. 转换成为字符串 
    char timebuffer[64];
    snprintf(timebuffer, sizeof(timebuffer), "%4d-%02d-%02d %02d:%02d:%02d",
             currtm.tm_year + 1900,
             currtm.tm_mon + 1,
             currtm.tm_mday,
             currtm.tm_hour,
             currtm.tm_min,
             currtm.tm_sec);

    return timebuffer;
}

///////////////////////////////////////////////////////////////////
// 1. 刷新的问题 -- 假设我们已经有了一条完整的日志,string->设备(显示器,文件)
// 基类方法
class LogStrategy
{
public:
    virtual ~LogStrategy() = default;
    virtual void SyncLog(const std::string &logmessage) = 0;
};

// 显示器刷新
class ConsoleLogStrategy : public LogStrategy
{
public:
    ~ConsoleLogStrategy()
    {
    }
    void SyncLog(const std::string &logmessage) override
    {
        {
            LockGuard lockguard(&_lock);
            std::cout << logmessage << std::endl;
        }
    }

private:
    Mutex _lock;
};

const std::string logdefaultdir = "log";
const static std::string logfilename = "test.log";

// 文件刷新
class FileLogStrategy : public LogStrategy
{
public:
    FileLogStrategy(const std::string &dir = logdefaultdir,
                    const std::string filename = logfilename)
        : _dir_path_name(dir), _filename(filename)
    {
        LockGuard lockguard(&_lock);
        if (std::filesystem::exists(_dir_path_name))
        {
            return;
        }
        try
        {
            std::filesystem::create_directories(_dir_path_name);
        }
        catch (const std::filesystem::filesystem_error &e)
        {
            std::cerr << e.what() << "\r\n";
        }
    }
    void SyncLog(const std::string &logmessage) override
    {
        {
            LockGuard lockguard(&_lock);
            std::string target = _dir_path_name;
            target += "/";
            target += _filename;

            std::ofstream out(target.c_str(), std::ios::app); // append
            if (!out.is_open())
            {
                return;
            }
            out << logmessage << "\n"; // out.write
            out.close();
        }
    }

    ~FileLogStrategy()
    {
    }

private:
    std::string _dir_path_name; // log
    std::string _filename;      // hello.log => log/hello.log
    Mutex _lock;
};

// 网络刷新

////////////////////////////////////////////////////////

// 1. 定制刷新策略
// 2. 构建完整的日志
class Logger
{
public:
    Logger()
    {
    }
    void EnableConsoleLogStrategy()
    {
        _strategy = std::make_unique<ConsoleLogStrategy>();
    }
    void EnableFileLogStrategy()
    {
        _strategy = std::make_unique<FileLogStrategy>();
    }
    // 形成一条完整日志的方式
    class LogMessage
    {
    public:
        LogMessage(LogLevel level, std::string &filename, int line, Logger &logger)
        : _curr_time(GetCurrentTime()),
          _level(level),
          _pid(getpid()),
          _filename(filename),
          _line(line),
          _logger(logger)
        {
            std::stringstream ss;
            ss << "[" << _curr_time << "] "
               << "[" << Level2String(_level) << "] "
               << "[" << _pid << "] "
               << "[" << _filename << "] "
               << "[" << _line << "]"
               <<  " - ";
            _loginfo = ss.str();
        }
        template<typename T>
        LogMessage& operator << (const T &info)
        {
            std::stringstream ss;
            ss << info;
            _loginfo += ss.str();
            return *this;
        }

        ~LogMessage()
        {
            if(_logger._strategy)
            {
                _logger._strategy->SyncLog(_loginfo);
            }
        }
    private:
        std::string _curr_time; // 日志时间
        LogLevel _level; // 日志等级
        pid_t _pid; // 进程pid
        std::string _filename;
        int _line;

        std::string _loginfo; // 一条合并完成的,完整的日志信息
        Logger &_logger; // 提供刷新策略的具体做法
    };
    LogMessage operator()(LogLevel level, std::string filename, int line)
    {
        return LogMessage(level, filename, line, *this);
    } 
    ~Logger()
    {
    }

private:
    std::unique_ptr<LogStrategy> _strategy;
};

Logger logger;

#define LOG(level) logger(level, __FILE__, __LINE__)
#define EnableConsoleLogStrategy() logger.EnableConsoleLogStrategy()
#define EnableFileLogStrategy() logger.EnableFileLogStrategy()
代码语言:javascript
复制
#pragma once

#include <iostream>
#include <mutex>
#include <pthread.h>

class Mutex
{
public:
    Mutex()
    {
        pthread_mutex_init(&_lock, nullptr);
    }
    void Lock()
    {
        pthread_mutex_lock(&_lock);
    }
    void Unlock()
    {
        pthread_mutex_unlock(&_lock);
    }
    pthread_mutex_t *Get()
    {
        return &_lock;
    }
    ~Mutex()
    {
        pthread_mutex_destroy(&_lock);
    }
private:
    pthread_mutex_t _lock;
};

class LockGuard
{
public:
    LockGuard(Mutex *_mutex):_mutexp(_mutex)
    {
        _mutexp->Lock();
    }
    ~LockGuard()
    {
        _mutexp->Unlock();
    }
private:
    Mutex *_mutexp;
};

上面的logger.hpp和Mutex.hpp是我之前就已经实现过的,所以这里就不讲解了,毕竟也不是我们要讲的重点。

6.2代码实现

那么下面我们就来实现一下里面的各种功能。

6.2.1Init函数

那么我们首先要实现的就是Init函数,也就是要完成初始化的工作,我们下面来看:

那么首先我们要认识的函数就是:socket,这个函数的作用就是向操作系统申请一个通信端点,就像我们想要打电话首先得有电话吧,而这个函数的作用就相当于我们买了一个电话机,有了电话机,我们就有了通信的前提。

那么下面我就来一一介绍这个函数的三个参数:

1.int domain

这个参数就是用来决定通信的范围,比如:AF_INET就是用于IPV4跨网络通信,AF_INET6就是用于IPV6跨网络通信,AF_UNIX就是用于本地通信。

除了上面列举的三种, 还有很多,这里就不一一介绍了。

2.int type

而这个参数是用来决定通信的模式,比如:SOCK_STREAM叫做流式模式,对应的就是TCP协议,SOCK_DGRAM叫做数据报模式,对应的就是UDP协议。

这个参数就简单理解就是我们和别人通信的方式,是用打电话的形式进行实时通信呢,还是像发邮件一样非实时通信。

3.int protocol

这个参数的作用就是指定具体的传输协议,这个参数我们一般填0,表示让系统自动选择一个默认的,最合适的协议。

就像上面如果前面两个参数填了AF_INET和SOCK_STREAM,那么系统就会自动匹配TCP,如果前面两个参数填了AF_INET和SOCK_DGRAM,那么系统就会自动匹配UDP。

在介绍了socket函数的三个参数后,下面我们来看看它的返回值:

可以看到该函数的返回值是一个int类型的整数,并且返回的是file descriptor也就是我们曾经讲过的文件描述符。

所以现在我们就知道了socket函数本质就是在内核中创建一个文件,并为其分配了文件描述符,这其实正印证了Linux中" 一切皆文件 "的设计哲学,并且我们也可以看到后面的接口都会用到socket函数的返回值。

那么讲到这里我们就可以完成初始化的第一步了,我们来看:

既然后面都要用到socket函数的返回值,那我们的第一个成员变量就能够确定了,上面的代码很简单,通过调用socket函数给_sockfd赋值,并简单判断一下,输出一下日志。

当然仅仅只是创建socket是不够的,我们下面接着看:

那么下面我要认识的函数就是:bind,这个函数的作用就是给上面socket函数创建的文件绑定一个具体的地址(IP地址和端口号)。

我们可以这样来理解这个函数的作用:我们说上面的socket函数就相当于我们买了个电话机,但是我们只有电话机没用啊,别人只知道你有电话机,但是却不知道你的电话号码,那自然也就无法联系你,而bind函数的作用就是给你的电话机" 上号 ",这样你有电话机和电话号码,之后你就可以给别人打电话或者接听别人打来的电话了。

在了解了bind函数的作用后,下面我们简单介绍一下它的各个参数:

第一个参数sockfd不用多说,就是socket函数返回的文件描述符,第二个参数上面我们也讲过,是一个结构体,后面我们再来看这个结构体的具体内容,而最后的参数就是这个结构体的大小。

之后我们再认识一下它的返回值:

返回值很简单,成功了就返回0,失败了就返回-1,错误码被设置。

那么我们先来写代码,写完之后我们再一一解释:

我们上面既然说了要通过bind给文件绑定一个地址,即IP地址和端口号,所以这个结构体中一定是有这两个参数的,所以我们直接将这两个参数作为我们的成员变量,不过这里的IP地址只是暂时这样写,因为我们看到的IP地址一般是是" 点分十进制 "字符串,这样的可读性好。 并且不光要设置IP和端口号这两种属性,还要重新设置sin_family也就是socket函数的第一个参数int domain,所以这时候就有一个疑问:为什么上面的socket函数已经设置了该参数,这里还要重新设置呢? 打个比方:上面的socket函数其实就像一扇门,内核根据你传入的各种参数在底层已经为你准备好了门后你要用到的各种东西,但是你想要打开这扇门就需要钥匙,而sin_family就是这把钥匙,我们调用bind函数就是检验这把钥匙对不对,如果对的话那么这扇门就可以和IP地址,端口号成功关联起来了。

那么解决了上面的疑问后下面我们就来真正看看这个结构体:

代码语言:javascript
复制
/* Structure describing an Internet socket address.  */
struct sockaddr_in
  {
    __SOCKADDR_COMMON (sin_);
    in_port_t sin_port;			/* Port number.  */
    struct in_addr sin_addr;		/* Internet address.  */

    /* Pad to size of `struct sockaddr'.  */
    unsigned char sin_zero[sizeof (struct sockaddr)
			   - __SOCKADDR_COMMON_SIZE
			   - sizeof (in_port_t)
			   - sizeof (struct in_addr)];
  };
代码语言:javascript
复制
/* Internet address.  */
typedef uint32_t in_addr_t;
struct in_addr
  {
    in_addr_t s_addr;
  };

这就是该结构体的完整样貌,里面有我们刚才所设置的IP地址和端口号,但是我们只看该结构体的内容的话是看不到我们所设置的sin_family参数的,它其实就是该结构体第一行的内容,我们来看:

它其实是一个宏,根据传入的sin_,##符号就是拼接的意思,所以sin_和family拼接在一起就是我们所看到的sin_family了。

看完该结构体的内容,我们此时回到代码中,可以看到我们所写的代码报错了,因为类型不匹配,上面也说了我们平常看到的IP地址是" 点分十进制 "的字符串,但是IP地址本质还是一个4字节32位的整数,所以我们还要做这两种类型之间的转换工作,那该如何做呢?

我们此时要用到的函数就是inet_addr,该函数就可以帮我们完成上面的工作:

当我们使用该函数后,就不再报错了,但是只修改IP地址还是不够的,我们在最上面也说了还有网络字节序的存在,我们可能并不知道自己当前的主机是大端还是小端,而网络要求必须要传大端数据,所以我们对于端口号也要进行修改,那么该如何保证端口号是大端的呢?

我们就要用到这些函数,前面两个是将IP地址和端口号从本地序转化为网络序,而后面两个则是将收到的IP地址和网络序从网络序转化为本地序。

所以这里我们要用到的函数就是:htons,将我们的端口号转化为网络序:

那么目前我们的Init函数就叫基本写完了,前置工作都完成了,下面我们就是启动这整个过程。

6.2.2start函数

那么首先我们先定义一个isrunning的变量,用来记录当前程序的运行状态,便于我们后续的操作。

而在函数中呢可以看到我让整个程序以死循环的形式运行,这里可能有人会疑惑为什么要设计成死循环,我们可以思考一下:我们电脑上的软件是打开它之后,一会儿它自己就关闭了吗?

很明显不是,我们只要没主动关闭,他就会一直在运行,也就是这些软件在启动之后就是一个死循环,所以这里我们将其设计成死循环是一样的道理。

我们下面就要进入代码的主要部分了,既然我们写的是服务端,那么首先要做的就是接收来自客户端发来的信息,所以我们的第一步工作该如何做呢?我们来看:

这里我们要用到的函数就是:recvfrom,这个函数看着参数很多,但是并不复杂,下面我们就来介绍一下它各个参数的含义:

sockfd不必多说,就是socket函数的返回值,上面我们已经将其作为成员变量了。 buf和len很容易理解,我么既然要接收来自客户端的信息,那么就要有容器来接收这些信息吧,所以这两个分别就是接受信息的容器和容器大小。 flags这个参数其实我们之前在讲解进程等待的时候就已经见过了,这个参数就决定服务端等待消息是阻塞式等待还是非阻塞式等待,我们一般将其设置为0,表示阻塞式等待。 src_addr和addrlen这两个参数是输出型参数,客户端给服务端发送消息,未来服务端也想给客户端发送消息得知道客户端的IP地址和端口号吧,所以这两个参数带出来的就是客户端的相关信息和该结构体的大小。

那么下面就是该函数的返回值,它的返回值类型和我们曾经讲过的read函数的返回值类型是一样的,含义也是一样的,成功读取的话读出来的就是具体的字节数,失败就返回-1。

那么在介绍完了该函数后,下面我们就可以写一部分代码了:

在有了该函数后,我们就可以写出上面的部分代码了,既然客户端给服务端发了消息,服务端自然也要给出回应,不过这里我们简单一点,客户端给服务端发什么,服务端就回什么。

那么下面的问题就是:服务端怎么主动给客户端发消息呢?

此时我们这里有需要用到一个函数了,那就是:sendto,该函数的参数和recvfrom一样多,并且长得非常相似,实际上它们的含义就是很相似。

所以这里的参数就不再一一介绍了,我们简单提一下最后两个参数,既然要给客户端发消息,那么自然要知道客户端的IP地址和端口号,而上面我们已经通过recvfrom函数得到了客户端的IP地址和端口号,所以这里直接传即可。

这里我们直接调用该函数即可,至此初始化和启动的工作就全部完成了,那么下一步就是由服务端来调用这些函数启动服务,那么下一步就是完善我们服务端main函数的代码。

上面就是我们所补充的服务端main函数中的代码,这里我们简单的固定了一下格式,必须是:./文件 + serverip + serverport的形式。

6.2.3完善代码

上面我们已经把服务端的代码全部完成了,那么要想通信还需要客户端的参与,下面我们就来看看客户端的代码该如何写:

按照前面讲的知识我们将代码写到了这里,那么下面我的问题是:对于客户端而言,我们需要显示的bind自己的IP地址和端口号吗?

答案是不需要,如果在这里我们像客户端一样显示指定了port,就比如:8080,拿我们手机上的软件来举例:抖音的程序员所写的客户端port指定为8080,而淘宝的程序员所写的客户端port也是8080。 那么我们就无法同时运行这两个软件,因为在我们的手机上8080端口号只有一个,在同一之间只能绑定一个进程,这种事情肯定是不允许发生的。 所以这里的做法就是我们不需要主动去bind,当我们recvfrom或者sendto的时候,操作系统会自动帮我们完成bind的工作,端口号是操作系统为我们自动分配的,这样做就不会出现上面端口号冲突的问题了。

那么既然不需要我们在做bind的工作了,那么下面我们直接进入到下一个阶段:

这里我们依旧通过循环来进行传递信息,我们要知道:一般一个公司的客户端和服务端,服务端会将自己的IP地址和端口号内置在客户端中,所以我们在使用客户端时没有传入什么IP地址和端口号,直接就能和服务端进行通信。

但我们今天所写的代码没有那么高级,所以这里我们手动将服务端的IP地址和端口号交给客户端,这样客户端就能根据IP地址和端口号向服务端发送消息。

上面的代码我们都见过,所以这里就不再介绍了,那么下面我们就来看看效果:

从结果我们可以看到客户端和服务端成功进行了通信,这里我们要介绍一下我们所用到的IP地址:127.0.0.1,这个IP地址叫做本地环回地址,这个地址的作用就是可以让我们进行本地测试,就是实现同一台主机两个进程之间的通信。

至此就完成了所有的工作,实现了客户端和服务端之间的通信,但是我们的代码还可以进行优化,我们先来看看我们的代码的局限:

这次我传给客户端的IP地址是内网地址,它和127.0.0.1一样代表的都是当前主机,但是从现象中我们可以看到,两者无法通信,主要是因为服务端绑定死了127.0.0.1这个IP地址,只要不是这个IP地址,就无法与之通信。

很明显这种方式局限性太大了,所以我们不建议服务端绑定固定的IP,所以我们的做法就是让服务端绑定任意IP地址,该怎么做呢?下面我们来看:

我们只需要将UdpServer.hpp中的ip地址设置为INADDR_ANY即可,它是一个宏,表示的就是任意IP地址,这个任意不是我们认为的所有的IP地址,而是能够指向当前主机的IP地址,如:本地环回地址(127.0.0.1),内网IP,公网IP等。

并且我们将其设置为任意IP后,我们的代码中就不再需要我们之前设置的_ip这个变量了,它的作用已经被INADDR_ANY所代替了,那么我们的代码就变为了:

那么修改后是否能通信呢?我们来看看:

从结果中我们可以看到客户端和服务端已经可以通信了,客户端我们不需要修改,还是得表明要访问的IP地址。

代码语言:javascript
复制
#pragma once
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <cstdlib>
#include <string.h>
#include "logger.hpp"
using namespace std;

const int defaultsockfd = -1;

class UdpServer
{
public:
    UdpServer(uint16_t port)
        : _sockfd(defaultsockfd),
          _port(port),
          isrunning(false)
    {
    }

    void Init()
    {
        // 1.创建socket
        _sockfd = socket(AF_INET, SOCK_DGRAM, 0);
        if (_sockfd < 0)
        {
            LOG(LogLevel::FATAL) << "create socket error!";
            exit(1);
        }
        LOG(LogLevel::INFO) << "create socket success";

        // 2.bind
        // 2.1填充IP和端口号
        struct sockaddr_in local;
        bzero(&local, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(_port); // 将端口号转为网络大端序
        // local.sin_addr.s_addr = inet_addr(_ip.c_str());
        local.sin_addr.s_addr = INADDR_ANY; // 表示任意IP

        // 2.2进行bind
        int n = bind(_sockfd, (const struct sockaddr *)&local, sizeof(local));
        if (n < 0)
        {
            LOG(LogLevel::FATAL) << "bind error!";
            exit(2);
        }
        LOG(LogLevel::INFO) << "bind success";
    }

    void Start()
    {
        isrunning = true;
        while (isrunning)
        {
            // 死循环运行
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            char buffer[1024];
            ssize_t n = recvfrom(_sockfd, buffer, sizeof(buffer), 0, (struct sockaddr *)&peer, &len);
            if (n > 0)
            {
                buffer[n] = 0;
                LOG(LogLevel::INFO) << "client echo: " << buffer;

                // 这里简单一点,客户端给服务端发什么,服务端就回什么
                string echo_string = "server echo: ";
                echo_string += buffer;

                sendto(_sockfd, echo_string.c_str(), echo_string.size(), 0, (struct sockaddr *)&peer, len);
            }
        }
    }

    void Stop()
    {
        isrunning = false;
    }

    ~UdpServer()
    {
    }

private:
    int _sockfd;
    uint16_t _port; // 端口号

    bool isrunning; // 判断当前程序的状态
};
代码语言:javascript
复制
#include "UdpServer.hpp"
#include <memory>

void Useage(string proc)
{
    cerr << "Usage: " << proc << "serverport" << endl;
}

int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        Useage(argv[0]);
        exit(0);
    }

    uint16_t port = stoi(argv[1]);

    EnableConsoleLogStrategy();
    unique_ptr<UdpServer> local = make_unique<UdpServer>(port);
    local->Init();
    local->Start();

    return 0;
}
代码语言:javascript
复制
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <string.h>
using namespace std;

void Useage(string proc)
{
    cerr << "Usage: " << proc << "serverip serverport" << endl;
}

int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Useage(argv[0]);
        exit(0);
    }

    string ip = argv[1];
    uint16_t port = stoi(argv[2]);

    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0)
    {
        cout << "create socket error" << endl;
        exit(1);
    }

    struct sockaddr_in server;
    memset(&server, 0, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(port);
    server.sin_addr.s_addr = inet_addr(ip.c_str());

    while (true)
    {
        // 1.向服务端发送数据
        cout << "Please Enter# ";
        string line;
        getline(cin, line);

        sendto(sockfd, line.c_str(), line.size(), 0, (struct sockaddr *)&server, sizeof(server));

        // 2.接收服务端的数据
        struct sockaddr_in temp;
        socklen_t len = sizeof(temp);
        char buffer[1024];
        ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer), 0, (struct sockaddr *)&temp, &len);
        if (n > 0)
        {
            buffer[n] = 0;
            cout << buffer << endl;
        }
    }
    return 0;
}

至此我们就完成了所有的工作,最后奉上本例的代码。

以上就是揭秘互联网的“第一道门”:初识 Socket 编程,亲手推开网络通信的奇妙世界的全部内容。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2026-01-02,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一.理解源IP地址和目的IP地址
  • 二.认识端口号
    • 2.1端口号范围划分
    • 2.2理解源端口号和目的端口号
    • 2.3理解socket
  • 三.传输层的典型代表
    • 3.1有连接和无连接
    • 3.2可靠传输和不可靠传输
  • 四.网络字节序
  • 五.socket编程接口
  • 六.Socket编程实现Echo server
    • 6.1准备工作
    • 6.2代码实现
      • 6.2.1Init函数
      • 6.2.2start函数
      • 6.2.3完善代码
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档