C编写的TCP例子随笔
代码
目的
代码部分有详细的注释标记了此处的注意事项和正在做的事情
目录结构
.
├── cli
├── serv
├── sockcli.c
├── sockserv.c
├── str_echo.c
├── str_echo.h
├── waitchild.c
└── waitchild.h
其中cli和serv为编译好的客户端和服务端代码
服务端代码
- sockserv.c
#ifndef __unp_h
#include "unp.h"
#endif
#include "signal.h"
#include "waitchild.h"
#ifndef UNTITLED_STR_ECHO_H
#include "str_echo.h"
#endif
int main(int argc, char **argv){
int listenfd, connfd;
pid_t childpid;
socklen_t clilen;
struct sockaddr_in cliaddr, servaddr;
// 创建套接字描述符
// Returns a file descriptor for the new socket, or -1 for errors.
listenfd = Socket(AF_INET, SOCK_STREAM, 0);
if (listenfd==-1){
exit(0);
}
// 参数1:协议族,此为IPv4协议
// 参数2:套接字类型,此为字节流套接字,
// 参数3:一般设为0,让系统选择协议类型,不然可选类型通常有 IPPROTO_TCP、IPPROTO_UDP、IPPROTO_SCTP
// 结构体初始化为0????????????????????????????????????????????????
bzero(&servaddr, sizeof(servaddr));
// 设置服务协议为IPv4
servaddr.sin_family = AF_INET;
// 设置服务器协议地址,此处设置为全0,所以htonl是非必须的
servaddr.sin_addr.s_addr= htonl(INADDR_ANY);
// 设置服务器协议端口
servaddr.sin_port = htons(SERV_PORT);
// hton*函数可以将主机字节序的数字转为网络字节序
// 因为sa_data是需要在网络上传输的,但family不用,所以family不用转为网络字节序
// 绑定一个协议地址到一个套接字
Bind(listenfd, (SA *) &servaddr, sizeof (servaddr));
// 第一个参数为套接字描述符
// 第二个参数为将sockaddr_in指针转为 sockaddr指针,注意sockaddr_in结构体中有对sockaddr的填充
// listen函数将套接字转换为被动套接字(默认为主动套接字也就是客户端),并将套接字状态从CLOSED状态转换为LISTEN状态.
Listen(listenfd, LISTENQ);
// 第二个参数为最大连接数
// 注册SIGCHLD的信号处理函数
signal(SIGCHLD, wait_child);
for(;;){
// 获取套接字长度
clilen = sizeof(cliaddr);
// 获取已连接连接队列(已完成三次握手的连接)获取队头的连接,如果已连接链接队列为空,程序进入睡眠(如果监听套接字为默认阻塞方式)
connfd = Accept(listenfd, (SA *) &cliaddr, &clilen);
// 返回值为<已连接套接字>
// 第一个参数为<监听套接字>,此套接字在一个进程中只存在一个,而已连接套接字不是
// 第三个参数为值-结果参数,函数执行完毕后其结果为该套接字地址结构中的准确字节数
if (connfd<0){
if (errno==EINTR){ // 因为在子进程发送信号,在处理信号函数返回时可能会出现系统中断,所以在这检测重启
continue;
} else{
err_sys("serv: accept failed");
}
}
if ((childpid=Fork()) == 0) { // 此处判断是父进程还是子进程,如果是父进程,此处为子进程的pid;如果是子进程,此处为0
//fork函数会返回两次,一次在父进程中,一次在子进程中
//fork有两种用法
// 一种是创建一个父进程的副本进程,进行某些操作
// 一种是在创建一个副本进程(子进程)后,在子进程中执行exec*函数,这样这个子进程映像就会被替换为被exec的程序文件,而且新的程序通常从main函数执行
// 如果是子进程,执行业务函数
str_echo(connfd);
// 关闭描述符,其实不关闭也可以,因为exit函数本身在内核中会将全部描述符关掉
Close(listenfd);
Close(connfd);
// 关闭进程
exit(0);
}
Close(connfd);
}
}
- str_echo.h
#ifndef UNTITLED_STR_ECHO_H
#define UNTITLED_STR_ECHO_H
#endif //UNTITLED_STR_ECHO_H
#ifndef __unp_h
#include "unp.h"
#endif
void str_echo(int connfd);
void simpleLogN(char* str);
- str_echo.c
#include "str_echo.h"
// 这仅是一个简单的往文件写入字符串的函数,替代日志
void simpleLogN(char* str)
{
// 注意此处使用自己的路径
const char* filename = "/home/loubw/l.txt";
FILE* fptr = fopen(filename , "w");
if (fptr == NULL)
{
puts("Fail to open file!");
exit(1);
}
fputs(str, fptr);
fputs("\n", fptr);
fclose(fptr);
}
void str_echo(int connfd){
ssize_t n;
// 用于保存read函数的返回值,获取此次读取的字节数
char buf[MAXLINE];
// buffer,用于保存read的字节
// 循环读取套接字描述符中的字节
simpleLogN("sub process is running.");
while(1){
n= read(connfd, &buf, MAXLINE);
// read函数为慢系统调用,可能会一直阻塞
simpleLogN("get read in...");
if (n <0 && errno==EINTR){ // errno:获得系统的最后一个错误
// 如果n小于0并且是中断错误,重新进入这个循环(重启读取)
continue;
} else if (n==0){
// 如果n==0说明接收到客户的FIN,读取完毕,跳出循环
simpleLogN("read over but in while.");
break;
} else if (n<0){
simpleLogN("read ERR n<0.");
// 如果出现其他错误直接退出
err_sys("str_echo: read error");
}
//如果n>0,说明读取到数据,打印到命令行,并将其写入描述符给客户端
Fputs(buf, stdout);
Writen(connfd, buf, n);
}
simpleLogN("read over.");
}
- waitchild.h
#ifndef UNTITLED_WAITCHILD_H
#define UNTITLED_WAITCHILD_H
#endif //UNTITLED_WAITCHILD_H
void wait_child(int signo);
- waitchild.c
#include "stdlib.h"
#include "wait.h"
#include "waitchild.h"
#include "str_echo.h"
#ifndef _STDIO_H
#include "stdio.h"
#endif
void wait_child(int signo){// 本函数参数必须为传入的信号num
// 进程在结束时并不是真正意义的销毁,而是调用了exit将其从正常进程变为了僵死进程,
// 这样的进程不占内存,不执行,也不能被调用
// 在子进程退出时会给父进程发送SIGCHLD,如果父进程不对其进行wait,就会变成僵死进程
// 如果此时父进程被杀死,子进程就会变成孤儿进程,子进程的父进程变为init进程
// wait 和 waitpid
// wait在多个SIGCHLD信号发来时候只能执行一次,而多个SIGCHLD信号没有排队机制,所以只能处理其中一个子进程
// waitpid的返回值如果>0说明还有未终止的子进程,可以再while中进行判断从而处理所有的僵死进程
int stat;
pid_t pid;
while ((pid = waitpid(-1, &stat, WNOHANG)) >0){ // 注意要制定WNOHANG
simpleLogN("sub process is terminated.");
}
}
客户端代码
- sockcli.c
#ifndef __unp_h
#include "unp.h"
#endif
void str_cli(FILE* fp, int connfd){
char sendline[MAXLINE], recvline[MAXLINE];
// 初始化发送给服务器的字符串和接受的字符串
while (fgets(sendline, MAXLINE, fp)!=NULL){
// 阻塞获取用户输入
Writen(connfd, sendline, strlen(sendline));
// 写入到套接字描述符发送到服务器端
// 阻塞读取服务器端的返回
if (Readline(connfd, recvline, MAXLINE)==0){ // 为什么此处是readline而服务端是read????????????????????????????????????????
// 为0说明服务器关闭,退出
err_quit("str_cli: server terminated prematurely");
}
// 打印到stdout从服务器接受的字节
Fputs(recvline, stdout);
}
}
int main(int argc, char **argv){
int sockfd;
// 初始化套接字描述符
struct sockaddr_in servaddr;
// 初始化socket地址结构
if (argc<2){
err_quit("usage: sockcli <Server IP>");
}
sockfd = Socket(AF_INET, SOCK_STREAM, 0);
// 新建套接字描述符
servaddr.sin_family=AF_INET;
servaddr.sin_port= htons(SERV_PORT);
Inet_pton(AF_INET, argv[1], &(servaddr.sin_addr.s_addr));
// Inet_pton 为将传入的第二个参数(点分的IP字符串)转换为网络字节序的ip地址放到最后一个参数指向的内存中
int is_connected = connect(sockfd, (SA*)&servaddr, sizeof (servaddr));
// 连接服务器,这里没有使用书中的Connect函数,因为它和原生的connect返回值不同
if (is_connected==-1){
// 连接错误报错
fprintf(stderr, "connect failed error is %s\n", strerror(errno));
exit(0);
}
// 进行业务处理,这里捕获用户输入
str_cli(stdin, sockfd);
// 业务处理完毕退出
exit(0);
}
编译
服务端编译
gcc -w -o serv sockserv.c waitchild.c str_echo.c -l unp
注意,主函数的文件中引用的自己编写的头文件对应的c文件必须在编译时带上,否则会报undefined错误,其余选项的含义可以参见上篇博文:上篇
客户端编译
gcc -w -o cli sockcli.c -l unp
运行
运行命令
分别在两个shell中运行
./serv
./cli 127.0.0.1
运行效果
知识点
大端序与小端序
大端序和小端序都是针对字节(最小存储单元)而言,不是bit
- 最高有效位和最低有效位
像0101 1011,最左边为最高有效位,最右边为最低有效位,类似于十进制的最高位和最低位 - 大端序
像0x12345678这个数,12这个字节存在内存的最低地址,78这个字节存在最高地址,这样叫做大端序,更符合人类的阅读习惯,所以网络字节序是大端序(大端先进内存),这样的话最高有效位在低内存地址 - 小端序
像0x12345678这个数,78这个字节存在最低内存地址,12这个字节在最高内存地址,这叫做小端序(小端先进内存),这样的话最高有效位存在高内存地址 - 你可以通过这个程序确定你的机器是大端还是小端
#include "stdio.h"
union icunion{
short s;
char c[2];
};
// 因为读取内存是从低内存往高内存读取的,所以
// 如果打印出1 2就是大端, 2 1就是小端
int main(){
short inta=0x0102;
union icunion icunion_obj;
icunion_obj.s = inta;
for (int i=0;i<2;i++){
printf("%d\n", icunion_obj.c[i]);
}
}
socket数据结构
// socket的linux定义
struct sockaddr
{
__SOCKADDR_COMMON (sa_); /* Common data: address family and length. */
char sa_data[14]; /* Address data. */
};
// 上面的宏
#define __SOCKADDR_COMMON(sa_prefix) \
sa_family_t sa_prefix##family
// 方便进行填写的socket结构体
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)];
};
之所以出现sockaddr_in是因为sa_data这个字节数组是IP和端口的结合,不好填写,注意sockaddr_in后面补0的设计,这样保证sockaddr和sockaddr_in的内存大小是一样的
- sa_family_t 为无符号短整型,用来表示协议族
- sa_data表示端口号和IP
- C的结构体只是内存的组织方式,比如
#include <stdio.h>
#include "stdlib.h"
struct intStruct{
int a;
int b;
};
struct longStruct{
int c[2];
};
int main(){
struct intStruct *i = malloc(sizeof(struct intStruct));
i->a = 1;
i->b = 2;
//新建intStruct变量并赋值
struct longStruct *l = (struct longStruct*) i; //强转指针
printf("%d %d \n", l->c[0], l->c[1]); // 打印出 1,2
}
fork函数
- fork函数会派生出一个子进程,fork函数之后,一定是两个进程同时执行fork函数之后的代码,而之前的代码以及由父进程执行完毕。这个函数在网络编程中非常常用。
- fork函数会返回两次,父进程一次,子进程一次。子进程返回的是0而父进程返回的是创建的子进程的id。可以通过这个判断当前应该执行的代码。
- fork还有种用法是在子进程中调用exec(),从而使子进程变为一个新的程序,新程序从被exec的可执行文件的main函数开始执行,注意之所以叫新程序而不是新进程的原因是其pid并不变。
信号处理函数与僵死进程
- 进程的僵死状态是为了维护子进程的信息,以便将来父进程获取,包括其子进程ID、终止状态以及资源信息等。如果僵死进程的父进程被杀死,此时僵死进程被称为孤儿进程,其父进程id被置为1,也就是init进程,由init进程负责wait它们。
- 我们在fork出子进程时都需要对其退出时发出SIGCHLD信号时对其进行wait,这样就可以防止其变为僵死进程。
- 我们在进程中通过signal.h的signal函数注册信号处理函数。
- wait()和waitpid():wait在多个信号同时发来(多个子进程同时退出)时,只能使其中一个子进程结束僵死状态。因为多个信号是”不排队“的(书中所言)。而waitpid可以通过返回值的方式获取到此时还有无僵死子进程,从而将其wait,具体见上面wait_child函数。
已连接套接字和监听套接字
- 监听套接字在每个TCP服务端只有一个,其负责从Bind、Listen、到Accept的部分,当Accept函数返回,即三次握手结束后,我们就会得到一个已连接套接字,并根据这个套接字在子进程中与客户端进行交互。这就是一个服务端可以服务多个客户端的原理。已连接套接字的ip和端口记录的是对面的,而监听套接字记录的是自己的。
慢系统调用与中断处理
- 慢系统调用是类似于accept、read等阻塞式的可能永远不会返回的调用。当进程处于此种调用的状态中,收到某些信号(或者其他情况,暂时还不清楚)比如上面main函数在accept时可能会受到子进程的SIGCHLD信号就会触发中断(错误码EINTR)。此时如果我们不处理程序就会退出,而且我们在代码中选择了直接忽略,并重新进行循环。
运行流程
正常启动
- 服务端新建socket、bind、listen后得到一个监听套接字,此时调用accept阻塞等待进程中已连接连接队列队头的连接。
- 客户端新建socket、指定其端口和IP(注意指定的是服务器端的),调用connect函数。
- connect函数会与服务端进行三次握手,客户端的connect在第二次握手时得到返回值:一个已连接套接字。服务端的accept会在三次握手结束后得到返回值:-个已连接套接字。
- 服务器端accept返回后,主进程回到accept等待下一个已连接连接,子进程开始执行业务代码,并阻塞在read函数
- 客户端此时阻塞在fgets等待用户输入,用户输入后便调用writen将输入传输到服务器,然后进入read等待服务器返回数据,此时服务器的read读取到数据,再将数据通过writen传输给客户端。这样下次传输得以开始
正常终止
-
客户端捕获到用户收入EOF(ctrl+D),即fgets返回值是NULL,程序退出,而程序退出时内核做的一部分工作就是关闭套接字,这导致此时客户端会发送一个FIN给服务端,此时客户端处于FIN_WAIT_1状态,四次挥手开始,而服务端以ACK响应,此时服务端在CLOSE_WAIT状态。
-
当服务端收到FIN时,read函数返回0,处理完业务,进程退出,关闭套接字,此时向客户端发出四次挥手的第三包FIN,此时服务端进入LAST_ACK状态,等待客户端的最后一包ACK,如果等不到就长时间的处于LAST_ACK。
-
客户端向服务端发送四次挥手的第四包,ACK,此时客户端进入TIME_WAIT状态,而服务端收到ACK后进入CLOSED状态,连接安全关闭。
-
客户端在进入TIME_WAIT状态后,等待2MSL的时间(TCP包在网络上存在的最大时间*2),如果期间没有来自服务端的第三包FIN(当服务端没有收到ACK时会重发FIN),进入CLOSED状态,连接安全关闭。
-
-
感谢此条博文: link以理解四次挥手以及TIME_WAIT的作用