2026/10/8 1:17:18

用C语言实现高效端口扫描:TCP connect、select超时与多线程并发

用C语言实现高效端口扫描:TCP connect、select超时与多线程并发 简介这是一份面向计算机网络与信息安全方向课程设计的端口扫描工具完整资源包采用C和Go两种语言分别实现覆盖TCP-connect、SYN、FIN、UDP四种常见扫描方式。压缩包共25个文件、大小6.35MB内含课程设计报告docx、答辩PPTpptx、C语言源码.c/.h、Go源码.go、技术报告/README说明文档md以及Makefile等构建配置目录结构清晰可对照报告直接编译运行。实现上Go版本采用协程与生产者消费者模型通过多生产者发送探测、多消费者监听返回实现异步并行显著减少socket IO等待C版本借助多线程轮询多个socket描述符只要其中某个IO有响应即可判定端口开放兼顾速度与可靠性。整套交付内容包含可运行源码、设计报告和答辩PPT既能支撑课程设计全过程也适合想学习并发网络编程、四种扫描原理的同学深入参考。当前已有1434人学习下载。1. 端口扫描工具在 C 语言里能解决什么问题做内网基线核查或者交 C 语言课程设计的时候十有八九会遇到这个需求给我一台机器的 IP我想快速知道它开没开 22、80、3306 这类常用端口。用 Python 写网络脚本确实够快但如果你想把这套逻辑压缩成一个不依赖外部库、拷到哪台机器都能编译跑起来的工具C 语言是更扎实的选择。这篇文章要讲的端口扫描工具核心就三件事用 socket 系统调用发起 TCP connect用 select 控制超时用多线程提高探测速度。它不是什么黑科技但把它读透之后你对 TCP 状态机、阻塞与非阻塞 I/O、文件描述符生命周期这些概念都会建立起很直观的认知。这篇文章适合正在学网络编程、要做 C 语言课设、或者需要做轻量端口基线核查的读者。2. 为什么用 TCP connect 做端口扫描三次握手与超时机制2.1 端口开放状态与 TCP 状态机SYN、ACK、RST 分别告诉你什么端口扫描的本质是向目标主机的某个端口发起连接请求然后根据回应判断这个端口的状态。最常见的做法是 TCP connect 扫描完整走一遍三次握手连接成功就认为端口开放。这里我们要先把手握手的细节讲清楚因为后面写代码时很多“奇怪现象”都得回到这一节来理解。客户端发送 SYN 报文后会遇到三种回复。第一种是 SYN-ACK说明目标端口处于监听状态愿意建立连接这是端口开放的最直接证据。第二种是 RST 报文说明目标主机的协议栈根本找不到对应端口在监听直接拒绝这就是端口关闭的典型表现。第三种是什么都不回没有 SYN-ACK 也没有 RST大概率是探测报文被防火墙规则丢弃了或者目标主机本身不可达。很多初学者把“没反应”当成“端口开放”这是第一个需要纠正的认知。把三种回应整理一下方便写代码时对应处理对端回应端口状态connect 系统调用表现说明SYN-ACK开放连接成功connect 返回 0最可靠但也最容易在目标日志里留下记录RST关闭connect 立即失败errno 为 ECONNREFUSED判定准确速度快无回应被过滤或不可达一直阻塞最终超时返回 EINPROGRESS 或超时需要结合超时机制兜底这里要额外说一句connect 扫描是“有痕”的因为三次握手已经完成目标服务日志会记录一次完整连接。如果只是做网络基线核查、了解自己内网资产开放了哪些端口这个痕迹完全可以接受。但如果你在探测不属于自己的网络资产请先停下手这涉及到授权和法规问题技术文章只讨论技术实现不鼓励越权使用。2.2 阻塞与非阻塞让超时控制在 1 秒而不是 75 秒确定了判定标准下一个要解决的就是超时。C 语言里直接调用 connectsocket 默认是阻塞模式。阻塞模式下如果目标端口不回 RST 也不回 SYN-ACKconnect 会卡在系统内核里按 TCP 重传机制反复发 SYN整个过程可能持续几十秒甚至超过一分钟。你写一个扫描工具目标防火墙如果对非开放端口做了 DROP 处理那一个端口就得等 75 秒扫完 1000 个端口要等 20 多个小时这工具就废了。解决办法有两个方向。方向一是给 socket 设置收发超时使用setsockopt和SO_SNDTIMEO、SO_RCVTIMEO让阻塞 connect 最多等待指定的秒数。方向二是把 socket 切换成非阻塞模式立即调用 connect然后交给 select 去等待连接结果。这两个方案我都用过方向上推荐后者因为 select 能给你更精细的控制不仅知道“连上了没有”还能区分“超时”“连接中”“立即失败”三种状态。非阻塞 connect 有一个必须理解的流程connect 返回 -1设置 errno 为 EINPROGRESS说明连接正在建立中。这时候调用 select 检查这个 socket 是否可写如果可写再通过 getsockopt 取出 SO_ERROR值为 0 才代表连接成功。这个流程看起来绕却是很多网络工具底层的标准做法。int flags fcntl(sock, F_GETFL, 0); fcntl(sock, F_SETFL, flags | O_NONBLOCK); int ret connect(sock, (struct sockaddr *)addr, sizeof(addr)); if (ret ! 0 errno ! EINPROGRESS) { return 0; // 立即失败RST 或网络错误 } fd_set wset; FD_ZERO(wset); FD_SET(sock, wset); struct timeval tv {timeout_sec, timeout_usec}; ret select(sock 1, NULL, wset, NULL, tv); if (ret 0) { return 0; // 超时 } int so_error 0; socklen_t len sizeof(so_error); getsockopt(sock, SOL_SOCKET, SO_ERROR, so_error, len); if (so_error 0) { return 1; // 连接成功端口开放 }这段代码的逻辑说明先切换非阻塞模式connect 不再死等内核重传然后 select 的监听对象是写事件连接建立成功的标志是 socket 变为可写最后一步 getsockopt 是为了确认 SO_ERROR 确实是 0因为 select 返回可写也有可能是收到了 RST这种时候 socket 同样可写。三个步骤缺一不可我第一次写的时候跳过了 getsockopt结果把一部分关闭端口误判成了开放这是个很典型的坑。参数方面timeout_sec 基本固定在 1 到 3 秒timeout_usec 一般设 0。局域网内扫描 1 秒足够跨公网可以适当放宽到 3 秒。两个值不要都设很大一个端口 3 秒65535 个端口理论最大要 54 小时后面第 4 章讲多线程并发时你会看到超时值直接决定了整体扫描耗时的上限。3. 单线程最小实现一个能跑通的端口扫描核心代码3.1 扫描主流程socket、connect、select、回收原理讲清楚了现在给一个能直接编译运行的完整版本。这个版本是单线程的功能很简单输入 IP、起始端口、结束端口逐个端口做判断开放就打印出来。它不适合扫大范围端口段但作为最小实现逻辑最干净排错也容易。真正做全量扫描在第 4 章的并发版本上扩展即可。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include fcntl.h #include sys/select.h #include sys/time.h int check_port(const char *ip, int port, int timeout_sec, int timeout_usec) { int sock socket(AF_INET, SOCK_STREAM, 0); if (sock 0) { perror(socket); return -1; } struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(port); if (inet_pton(AF_INET, ip, addr.sin_addr) ! 1) { fprintf(stderr, IP 地址格式错误\n); close(sock); return -2; } int flags fcntl(sock, F_GETFL, 0); if (fcntl(sock, F_SETFL, flags | O_NONBLOCK) ! 0) { close(sock); return -3; } int ret connect(sock, (struct sockaddr *)addr, sizeof(addr)); if (ret ! 0 errno ! EINPROGRESS) { close(sock); return 0; // 端口关闭或被拒绝 } fd_set wset; FD_ZERO(wset); FD_SET(sock, wset); struct timeval tv; tv.tv_sec timeout_sec; tv.tv_usec timeout_usec; ret select(sock 1, NULL, wset, NULL, tv); if (ret 0) { close(sock); return 0; // 超时不判定为开放 } if (ret 0) { close(sock); return -4; } int so_error 0; socklen_t len sizeof(so_error); getsockopt(sock, SOL_SOCKET, SO_ERROR, so_error, len); close(sock); if (so_error 0) { return 1; // 确认连接成功 } return 0; } int main(int argc, char *argv[]) { if (argc ! 4) { fprintf(stderr, 用法: %s IP 起始端口 结束端口\n, argv[0]); return 1; } const char *ip argv[1]; int start atoi(argv[2]); int end atoi(argv[3]); if (start 1 || end 65535 || start end) { fprintf(stderr, 端口范围不合法必须在 1-65535 之间\n); return 1; } printf(开始扫描 %s端口范围 %d-%d\n, ip, start, end); for (int port start; port end; port) { int result check_port(ip, port, 1, 0); if (result 1) { printf(%d open\n, port); fflush(stdout); } } printf(扫描结束\n); return 0; }这段代码的建议编译命令是gcc -o scan scan.c运行示例是./scan 127.0.0.1 22 443。整体流程是每个端口新建一个 socket设置非阻塞发起 connectselect 等待最多 1 秒最后 getsockopt 确认结果。注意每个分支都要 closesocket 是文件描述符资源不关的话进程跑完一轮就没有 fd 可用了。参数上的几个选择我补充说明一下。timeout 我在这里写死了 1 秒因为本机回环地址和局域网内响应基本在毫秒级。如果扫公网 IP建议改成 3 秒或者把超时值做成第五个参数这样不用重新编译。IP 解析直接用了inet_pton而不是gethostbyname好处是支持点分十进制输入且不需要处理 DNS 解析如果你要按主机名扫描要先把这一步换成getaddrinfo。3.2 输入参数与边界处理atoi 只是起点这个版本的输入处理暴露了一个问题atoi 不会检查数字合法性输入./scan 127.0.0.1 abc 100start 会变成 0程序会直接报“端口范围不合法”退出。更隐蔽的是输入1.1.1.1 65536 65537end 在 atoi 层面变成 65537但条件是 end 65535所以也会被拦住。这里的边界校验能挡住大多数误输入但还不够严格——atoi 对字符串里的非数字字符只会静默忽略比如80abc会被解析成 80。如果你想把工具做得稳一点可以把参数的解析换成strtol检查endptr是否指向字符串结尾再配合errno判断是否溢出。另一个容易被忽略的点是inet_pton对 IPv4 的检查它传人的地址必须是点分十进制且每段在 0-255 之间比如127.0.0.1没问题127.0.0.256会返回 0。这些边界处理看起来琐碎但真的到了给不懂技术的人演示工具的时候能把报错挡在前面体验会好很多。3.3 运行与验证先扫本机再扫局域网编译好之后别急着扫公网先扫本机回环地址。在大多数 Linux 主机上22SSH、80HTTP、3306MySQL这些端口要么真实开放要么服务没装但系统层面没有监听。本机扫描结果可以用ss -ltn或者netstat -ltn去对照哪些端口开着一查便知。我一般先跑./scan 127.0.0.1 20 100观察结果里有没有与ss -ltn输出重叠的端口号。如果扫出来与预期完全不一致优先怀疑ss -ltn没有显示是你权限不够换成sudo ss -ltn再比一次。回环测试通过后再扫局域网内一台已知开了 HTTP 服务的主机比如./scan 192.168.1.23 80 81预期 80 开放、81 关闭。这一步验证的是非阻塞 connect 在真实网络环境下的表现和回环环境相比多了 ARP 寻址、路由转发这些环节能暴露防火墙丢包导致的超时问题。4. 并发扫描设计与 3 个关键参数线程数、超时值、结果缓冲4.1 线程模型一个端口一个线程为什么不可行单线程版本扫 100 个端口每个端口最多等 1 秒最慢要 100 秒。这个速度显然不靠谱所以并发改造是必然的。最常见的做法是给每个端口开一个线程线程里调用第 3 章的check_port逻辑主线程只负责创建和回收。代码写起来很简单但这是我最不建议在生产环境抄的方案——65535 个端口就意味着同时创建 65535 个线程线程不是免费资源。Linux 下每个线程默认栈大小是 8MB这是虚拟内存地址空间的占用不会立刻吃掉物理内存但线程总数受内核参数限制kernel.threads-max、vm.max_map_count开太多线程会出现pthread_create failed而且线程切换开销会让整个扫描速度反而变慢。更稳妥的做法是引入信号量限制并发数比如同时最多跑 200 个线程扫完一个再补一个这样系统负载稳定速度也足够快。信号量的用法注意sem_init初始值设为最大并发数主线程每次创建任务前sem_wait工作线程完成后sem_post释放一个名额。这个模型的优点是线程数可控缺点是信号量操作有轻微开销但对于端口扫描这种网络 I/O 密集的任务来说这部分开销可以忽略。sem_t sem; sem_init(sem, 0, max_threads); // max_threads 200 for (int port start; port end; port) { sem_wait(sem); // 占用一个并发名额 port_arg *arg malloc(sizeof(port_arg)); strncpy(arg-ip, ip, sizeof(arg-ip) - 1); arg-port port; arg-timeout_sec timeout_sec; arg-timeout_usec timeout_usec; pthread_t tid; if (pthread_create(tid, NULL, thread_scan, arg) ! 0) { free(arg); sem_post(sem); continue; } pthread_detach(tid); // 线程结束自动回收资源 }线程函数thread_scan里复用的是check_port拿到结果为 1 时打印端口号最后free(arg)和sem_post。注意两个细节参数结构体必须在堆上malloc不能用栈上的局部变量因为线程刚启动时函数可能已经返回了线程用pthread_detach脱离主线程这样不用逐个pthread_join简化了回收逻辑。4.2 三个必调参数并发线程数、单连接超时、FD 上限并发扫描要调好三个参数缺一个都会在特定场景翻车。第一是最大并发线程数。默认 200 比较稳局域网内可以调到 500公网建议保留 200。这个值取决于目标机器的连接承受能力以及你运行扫描的机器本身。目标机器的net.core.somaxconn如果设置得小大量 SYN 同时涌进来可能会丢包导致一部分开放端口超时漏报。第二是单连接超时值。前面说过 1 秒适合局域网公网建议 3 秒。但要注意超时值和并发数是互相关联的200 个线程、每个线程等 3 秒每秒大约能探测 66 个端口扫完 65535 个端口需要约 16 分钟。要提速要么加线程数要么减超时值但两个方向都有上限。第三是文件描述符上限。每个线程一个 socket就是 200 个 fd看起来不多但ulimit -n默认值在部分发行版上是 1024如果你的并发数开到了 1000先确认这个限制。用ulimit -n查看如果不够就临时调大或者把并发数控制在软限制之内。#include pthread.h #include semaphore.h #include stdio.h #include stdlib.h #include string.h #include unistd.h #define MAX_THREADS 200 #define TIMEOUT_SEC 1 typedef struct { char ip[16]; int port; int timeout_sec; int timeout_usec; } port_arg; pthread_mutex_t print_lock PTHREAD_MUTEX_INITIALIZER; void *thread_scan(void *arg) { port_arg *p (port_arg *)arg; int result check_port(p-ip, p-port, p-timeout_sec, p-timeout_usec); if (result 1) { pthread_mutex_lock(print_lock); printf(%d open\n, p-port); fflush(stdout); pthread_mutex_unlock(print_lock); } free(arg); return NULL; }这段代码新增了两个关键点print_lock互斥锁保护打印操作避免多线程同时 printf 时输出交错free(arg)放在线程函数内部因为主线程创建完就继续干别的事了不能替子线程管理这块内存。这里也回应了之前热词里提到的 C 语言内存管理问题——多线程环境下堆内存的分配和释放必须做清晰的责任划分否则真会出现 double free 或者内存泄漏。4.3 结果收集直接打印还是集中汇总并发场景下每个线程直接向终端输出有三个问题。第一是混淆两个开放端口挨在一起打印你很难看出哪个是哪个。第二是性能printf 本身有锁、有缓冲刷新200 个线程同时打印会产生锁竞争。第三是后续处理困难结果散落在标准输出里你要统计“这轮扫到几个端口”得另写脚本去解析。我一般会为结果收集单独设计一个结构体数组每个线程把结果写进去主线程扫描结束后统一输出。数组用互斥锁保护端口号、开放状态、超时时间三项为一组。这里的容量预估开放端口通常占比很小给MAX_THREADS * 10的容量基本够用要注意数组越界仍然要加计数器判断。5. 端口扫描工具常见问题排查5 条踩坑记录5.1 connect 返回 ECONNREFUSED 其实是对的现象扫到某个端口日志显示 connect 返回 -1errno 是 ECONNREFUSED你以为是程序出错了。原因这恰恰是端口关闭的标准判定结果目标协议栈在收到 SYN 后回发了 RSTconnect 被拒绝。解决不用把它当错误处理它的语义就是“端口关闭”直接在代码里把这种情况映射为return 0即可。真正要警惕的是 EINPROGRESS 和超时那才说明端口状态不确定。5.2 每个端口都要新建 socket文件描述符会偷偷耗尽现象程序扫到大概 1000 个端口后突然报错 “socket: Too many open files”。原因ulimit -n默认 1024你的程序同时有 1000 个 socket fd 未关闭超过上限。解决第一确认每个分支都调用了 close第二检查是否用了FD_CLOEXEC或用close-on-exec标志第三调大ulimit -n。这个坑最坑的点在于它在单线程版本里不出现一旦并发拉高就暴露属于资源管理问题。5.3 select 返回可写端口也未必开放现象明明 getsockopt 返回的 SO_ERROR 是 0但该端口其实没有服务在监听。原因select 的“可写”含义只有一个——这个 socket 上的写操作不会阻塞包括收到 RST 之后 socket 也可以可写。解决不能省掉 getsockopt 那一步SO_ERROR 是 0 才能算连接成功。如果还想更严谨还可以在 getsockopt 之后调用一次getpeername验证但不推荐因为 multilink 场景下偶尔会有协议栈层面的干扰。5.4 多线程传参传了 i扫出一堆鬼画符端口现象并发版本扫出来的开放端口号完全不对77、553、6002 这种毫无规律的数值。原因线程函数的参数是循环变量i的地址主线程循环跑得飞快子线程读到的i早已不是开始的值甚至可能已经被主线程改过多次。解决参数结构体在堆上malloc然后逐字段赋值或者按值传参但 C 语言 pthread 只传 void 指针所以最安全的就是每个线程一个独立堆对象保证线程间互不干扰。这个坑几乎是每个写 C 多线程的人都要踩一次的。5.5 自己写的工具扫不到任何端口先查防火墙而不是怀疑代码现象扫描一个局域网主机所有端口都超时一个 open 都没有。原因目标主机防火墙默认策略大概率是 DROP直接丢弃入站 SYN你的 connect 一直等不到回复走超时分支。这不是代码逻辑错误是网络环境限制。解决先ping目标 IP 确认通再用nc -zv手动测一次已知开放端口判断是目标策略限制还是自己工具的问题。如果ping通但nc也超时就是防火墙拦截与工具无关。6. 扫描结果验证与进阶技巧从“能跑”到“可信”工具写完之后验证可信度比继续加功能更重要。我的验证习惯是先扫回环地址用ss -ltn对照再扫两台知道端口状况的局域网机器确认没有漏报和错报最后才敢扫一个未知网段。验证过程中如果发现结果不稳定大概率是并发数高导致目标机丢包这时把并发数降一半重扫即可不需要在代码层面做大改。进阶方向有三个值得投入。第一是重试机制对超时端口做第二遍扫描超时值放宽到 3 秒能显著降低公网环境下的误报率。第二是SYN半开扫描用 raw socket 直接发送 SYN 报文而不完成三次握手只根据 SYN-ACK 或 RST 判定端口状态速度快且连接痕迹少但需要 root 权限。第三是把结果输出改成日志落盘每次扫描带时间戳保存到文本文件方便后续资产状态对比。最后一个建议送给你把check_port函数做成独立的可复用模块不要在第 4 章那种把逻辑全部堆在 main 函数里的版本上继续添功能。我当时偷懒把线程、信号量、端口循环全写在一起结果调参时到处改代码最后花了两个晚上重构。先用第 3 章的小版本理清逻辑再上第 4 章的并发模型每步都验证这个顺序能帮你少走很多弯路。希望帮到你。本文还有配套的精品资源点击获取