简介:这份代码包围绕串口与网口之间的双向数据转发,面向具备C语言基础、从事工业控制、远程监控或嵌入式上下位机通信的开发者,用于解决串口设备与TCP/IP网络之间数据互通的问题。压缩包仅4KB,共5个文件,以2个C源文件和1个头文件为核心,配合Makefile完成编译组织,并附带说明文档,便于快速理解程序结构与构建方式。目前已有1709人学习下载。内容涉及串口termios配置、Socket收发、select多路I/O监听、多线程并发处理及线程同步,同时覆盖协议格式转换、异常处理与测试思路;通过共享队列衔接两个方向的数据流,读者可据此梳理从现场设备到上位机的数据链路,掌握双端转发程序的关键实现方法与排错要点。
1. 现场那根串口线不够长了
产线上有台老仪表,只有一路 RS-485 口,轮询协议跑得好好的,可采集程序在八十米外的机房服务器上。拉长线不现实,中间加中继又是一笔开销,最省事的办法是放一台小盒子在仪表边上,串口进去、网口出来,把这套「串口和网口数据相互转发」的活干稳。这份源码就是这件事的最小实现:纯 C,串口读到的字节原样塞进 TCP,TCP 收到的字节原样写回串口,不做协议解析,不做缓存重排,跑在任意 Linux 上。适合两类人:手里有老设备要联网、又不想上重型组态网关的现场工程师;以及想搞明白 termios 和 socket 两套 API 怎么在同一个 select 循环里和平共处的 C 开发者。
2. 架构选型:一个 select 循环管住串口和 socket 两条腿
2.1 线程模型:为什么不用「一个线程读串口、一个线程写网络」
刚接触这类透传需求的人,第一反应几乎都是开两个线程:串口线程 read 完丢进队列,网络线程从队列取出来 send。写起来直观,但实际维护起来是一堆麻烦。队列要加锁,收尾要处理线程退出,串口拔插时读线程卡在 read 上,主线程还得想办法把它捅醒。更麻烦的是调试——出了丢包,你得同时怀疑队列、锁和两个线程的调度顺序,黑匣子属性直接拉满。
这个项目选了另一条路:单线程 + select 多路复用。串口 fd 和监听 fd 一起挂到 fd_set 上,谁有事件处理谁,处理完继续下一轮。好处是没有并发状态,所有缓冲区都在一个线程里访问,不需要任何锁;坏处是一旦某次 write 阻塞,整条链路都卡住——所以所有 fd 都必须以非阻塞方式打开,这是硬约束。
| 方案 | 优点 | 代价 |
|---|---|---|
| 双线程 + 队列 | 读写互不阻塞,代码分支直观 | 需要锁和退出机制,问题定位跨线程 |
| 单线程 select | 无锁、状态集中、易调试 | 任一 fd 阻塞会拖垮全局,必须全非阻塞 |
| 单线程 epoll | 连接数多时扩展性好 | 这类场景通常只有一个串口一个客户端,收益不大 |
现场设备基本是「一个串口对一个客户端」,select 的 1024 fd 上限完全够用,没必要为了扩展性提前上 epoll。等真要做多串口汇聚,再换成 epoll 也只是把 fd_set 换成 epoll_event,主循环骨架不变。
2.2 缓冲区与数据结构:转发链路里到底存了什么
整条转发链路的运行状态就那么几个字段,我把它们收在一个结构体里,避免全局变量散落一地。核心是两块缓冲:串口来的数据和网络来的数据各一块,长度固定,够放下现场一帧报文就行。
#define BUF_SZ 4096 typedef struct { int uart_fd; /* 串口设备 fd,非阻塞 */ int listen_fd; /* TCP 监听 fd */ int client_fd; /* 当前连上的客户端,-1 表示无 */ unsigned char uart_rx[BUF_SZ];/* 串口读上来的原始字节 */ unsigned char net_rx[BUF_SZ]; /* 网络收到的原始字节 */ unsigned long stat_uart_bytes;/* 串口侧累计字节,排查用 */ unsigned long stat_net_bytes; } bridge_t;BUF_SZ取 4096 是个经验值:现场轮询报文一般几十到几百字节,4096 足够一次 read 把积压数据全带走,又不会因为缓冲区太大让延迟变高。client_fd初始化为 -1,后面所有判断都靠它区分「有没有客户端」,比另起一个 bool 更省事。两个stat_*字段平时不用,出问题时打印出来,一眼能看出是不是只有单向通——这是排查时最省时间的一招。
两块缓冲都是「读进来立刻写出去」的用法,不做跨轮次缓存。这意味着如果对端写满、write返回EAGAIN,当前这批数据就丢了。要做得更稳,得给每条方向加一个待发送队列,代价是代码量翻倍。现场设备大多能容忍偶发丢帧(协议本身有重试),所以我选择先简单后完善。
2.3 目录结构与模块边界
工程没有引入任何第三方库,只依赖 libc 和 pthread 之外的标准头文件。模块按「设备操作」和「网络操作」切开,主循环只负责编排,不碰具体系统调用。
com-ip-bridge/ ├── Makefile ├── README.md ├── src/ │ ├── main.c # 参数解析、select 主循环 │ ├── uart.c / uart.h # 打开、配置、读写串口 │ ├── net.c / net.h # socket 监听、accept、收发 │ └── bridge.c/.h # 双向转发与统计 └── conf/ └── bridge.conf # 二次开发后的配置样例uart.c对外只暴露四个函数:uart_open、uart_read、uart_write、uart_close。net.c同样只暴露net_listen、net_accept、net_recv、net_send。这种划分的意义在于:以后想把串口换成 SPI 或者虚拟设备,只改uart.c一个文件,主循环一行不动。转发逻辑单独放在bridge.c,是因为后面要在转发路径上加协议钩子,有个独立文件改起来干净。
2.4 编译与第一次跑通
Makefile 就三条规则,不开优化方便调试,-g -Wall是标配。-D_GNU_SOURCE是为了拿到cfmakeraw这些 GNU 扩展。
CC ?= gcc CFLAGS ?= -g -O0 -Wall -Wextra -D_GNU_SOURCE SRC := $(wildcard src/*.c) bridge : $(SRC) $(CC) $(CFLAGS) -o $@ $(SRC) clean : rm -f bridge跑起来只需要四个参数:串口设备、波特率、监听地址、监听端口。
make # 串口 /dev/ttyUSB0,115200 8N1,监听所有网卡的 5000 端口 ./bridge -d /dev/ttyUSB0 -b 115200 -l 0.0.0.0 -p 5000 # 另一端验证:bash 内置的 /dev/tcp 就能当客户端用,不依赖额外工具 exec 3<>/dev/tcp/127.0.0.1/5000 printf 'hello' >&3 timeout 2 cat <&3第一次验证建议先拿虚拟串口做,别直接怼真设备。用 pty 模拟出一对串口,把其中一端给程序,另一端自己读写,能立刻确认「串口→网络」和「网络→串口」两个方向都通,再上现场硬件。参数含义很直白:-d是设备节点,-b是波特率(必须和设备一致,写错就是收一串乱码),-l和-p是监听地址端口,-l 127.0.0.1只给本机连,现场部署改成0.0.0.0或者指定内网网卡地址。
提示:程序默认阻塞在 select 上等事件,日志级别用
-v打开。第一次上现场,务必先把-v打开跑十分钟,看看有没有反复的 accept/断线,这比事后猜要省事得多。
3. 串口侧:用 termios 把 tty 调成一条裸管道
3.1 打开串口:O_NOCTTY 与 O_NONBLOCK 的取舍
打开串口设备节点时,open的 flag 里有两个参数必须想清楚。O_NOCTTY表示「别把这个终端当成本进程的控制终端」,少了它,串口设备发过来的某些字节序列可能被行规程当成控制字符,甚至导致进程收到信号。O_NONBLOCK是前面说的硬约束,select 循环里任何一个 fd 阻塞,整个转发就停了。
int fd = open(dev, O_RDWR | O_NOCTTY | O_NONBLOCK); if (fd < 0) { fprintf(stderr, "open %s: %s\n", dev, strerror(errno)); return -1; }非阻塞模式有个副作用:没有数据时read直接返回 -1 并把errno置成EAGAIN。这跟「读失败」看起来一样,必须显式区分。O_NOCTTY则常被省略,因为省略后大部分场景也「看起来正常」——直到某天设备回了一串包含 0x03 的数据,进程莫名其妙退出,才开始怀疑人生。这类玄学问题,翻车的代价远高于多写一个 flag。
3.2 raw 模式与 8N1:termios 里每个 c_cflag 位到底改了什么
串口默认是「终端」语义:按行缓冲、回显输入的字符、把\n翻译成\r\n、把 Ctrl+C 解释成中断信号。做数据透传,这些行为一个都不能留。cfmakeraw能一次性关掉绝大部分,但保险起见我还是把关键位显式写一遍,让后来接手的人看得懂每一行在干什么。
struct termios tio; if (tcgetattr(fd, &tio) != 0) { close(fd); return -1; } cfmakeraw(&tio); /* 关回显、关行缓冲、关信号字符 */ tio.c_cflag |= (CLOCAL | CREAD); /* 忽略调制解调器控制线,允许接收 */ tio.c_cflag &= ~CRTSCTS; /* 不做硬件流控 */ tio.c_cflag &= ~CSTOPB; /* 1 位停止位 */ tio.c_cflag &= ~PARENB; /* 无校验 */ tio.c_cflag &= ~CSIZE; tio.c_cflag |= CS8; /* 8 位数据位 */ tio.c_cc[VMIN] = 1; tio.c_cc[VTIME] = 0; cfsetispeed(&tio, baud); cfsetospeed(&tio, baud); if (tcsetattr(fd, TCSANOW, &tio) != 0) { close(fd); return -1; }逐项说。CLOCAL忽略载波检测,串口线没接好也不会卡住;CREAD允许接收,不写这个就读不到任何东西。CRTSCTS是硬件流控,现场设备十台有九台没接 RTS/CTS 线,开着它等于自断收发,必须清掉。CS8配合前面对CSTOPB、PARENB的清除,构成最常见的 8N1 组合。VMIN=1、VTIME=0表示「至少来一个字节才返回」,配合非阻塞 fd,实际行为是「有数据就返回,没数据返回 EAGAIN」。
波特率这里有个坑:cfsetispeed和cfsetospeed必须在tcsetattr之前调用,顺序反了不生效。另外,115200 以上的非标准波特率(比如 921600)在部分平台的speed_t里没有常量,得用termios2结构体加BOTHER走ioctl,标准 termios 搞不定。真遇到这种设备,别在cfset*上死磕,换条路。
3.3 VMIN/VTIME:读超时怎么设才不丢半包
VMIN和VTIME的组合决定了read什么时候返回,这两参数在透传场景里需要想明白。四种组合的行为差异很大:
| VMIN | VTIME | 行为 | 适用场景 |
|---|---|---|---|
| 0 | 0 | 立即返回,有就全给,没有返回 0 | 纯轮询,不配合 select 时别用 |
| 1 | 0 | 至少 1 字节才返回,无超时 | 本项目的选择,配合 select |
| 0 | n | 最多等 n×100ms,超时返回 | 定时上报型设备 |
| m | n | 满 m 字节或超时返回 | 定长帧协议 |
这个项目选VMIN=1, VTIME=0,因为等待的活交给了 select。select 报告串口可读,read立刻就能拿到数据,不会在里面再睡一次。反过来,如果设成VMIN=0, VTIME=5,select 醒了之后read还要再等 500ms 才返回,转发延迟直接翻倍。
真正需要注意的是「一次 read 不等于一帧」。串口是字节流,设备发一帧 20 字节,内核缓冲区可能只到了 8 字节就触发可读,read返回 8。这不是 bug,是正常行为。透传模式下无所谓,读到多少转多少,字节顺序不乱就行;但如果你在转发路径上做协议解析,就必须自己攒帧,攒够再处理。这一点在后面加帧钩子时会重点说。
3.4 部分写与 tcdrain:串口写不完怎么办
串口侧最常被忽略的是写。网络侧send返回部分写,很多人会写循环;串口侧同样会部分写,而且更容易——波特率 9600 时,往串口写 4KB 大约需要 4 秒多,这段时间里 UART 的发送缓冲区一直是满的,write返回实际写入的字节数远小于请求值。
static int uart_write_full(int fd, const unsigned char *buf, size_t len) { size_t off = 0; while (off < len) { ssize_t n = write(fd, buf + off, len - off); if (n > 0) { off += (size_t)n; continue; } if (n < 0 && errno == EINTR) continue; /* 被信号打断,重试 */ if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { usleep(1000); /* 发送缓冲满,让出 1ms */ continue; } return -1; /* EIO/ENODEV 等真错误 */ } return 0; }参数len是本次要写的总字节数,off是已写偏移,循环直到写满或者遇到不可恢复错误。EAGAIN分支里usleep(1000)是妥协做法——严格来说应该把剩余数据挂进待发送缓冲,由 select 的可写事件驱动,但那样代码复杂度上一个台阶。1ms 的让步在 115200 波特率下能腾出约 11 字节的空间,实际测试下来够用,而且不会明显拖慢主循环。
EIO和ENODEV必须和EAGAIN分开:前者通常意味着 USB 转串口被拔了,设备节点已经失效,继续重试没有意义,正确做法是关掉 fd、重新扫描设备节点、重新打开。把所有负返回值都当成「再等等」,程序就会卡在重试循环里,表现是进程还在但数据全断,这种状态最难查。
注意:
tcdrain会一直等到发送缓冲区彻底排空才返回,在非阻塞 fd 上同样会阻塞线程。主循环里不要调它,只在程序退出前需要确保最后一帧发完时用一次。
4. 网口侧:TCP 服务端模式的监听与转发
4.1 监听与接受:SO_REUSEADDR 与单客户端策略
网络侧默认工作在 TCP 服务端模式,设备主动连过来。监听流程就是标准三步:socket、bind、listen。中间有个容易忘的选项SO_REUSEADDR,作用是允许端口在 TIME_WAIT 状态下被重新绑定。调试期间频繁重启程序,没这个选项就会看到Address already in use,然后等上一两分钟。
int lfd = socket(AF_INET, SOCK_STREAM, 0); int on = 1; setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)); struct sockaddr_in addr = {0}; addr.sin_family = AF_INET; addr.sin_port = htons(port); addr.sin_addr.s_addr = inet_addr(bind_ip); /* "0.0.0.0" 表示全部网卡 */ bind(lfd, (struct sockaddr *)&addr, sizeof(addr)); listen(lfd, 4);inet_addr只支持 IPv4 点分十进制,传0.0.0.0表示监听所有网卡。listen的 backlog 给 4 就够了,这类场景不会有并发连接洪峰。生产环境如果只允许内网某台采集服务器连,把bind_ip写成具体网卡地址,比在应用层做白名单简单可靠。
这里明确采用单客户端策略:同一时刻只服务一个 TCP 连接。理由是串口是独占资源,两个客户端同时往串口写,数据在物理层就交织了,对端设备根本解不出来。所以accept到新连接时,如果client_fd已经不是 -1,直接关掉旧连接换新的——现场设备重连时通常不会先发 FIN,旧 fd 会一直占着,不主动替换就再也连不上了。
4.2 select 事件循环骨架
主循环是整个项目的心脏,逻辑就三块:把关心的 fd 塞进集合、等事件、按事件分派。串口 fd 和监听 fd 一直都在,客户端 fd 有时有时没有,所以要动态判断。
for (;;) { fd_set rset; FD_ZERO(&rset); FD_SET(b.uart_fd, &rset); FD_SET(b.listen_fd, &rset); int maxfd = b.uart_fd > b.listen_fd ? b.uart_fd : b.listen_fd; if (b.client_fd >= 0) { FD_SET(b.client_fd, &rset); if (b.client_fd > maxfd) maxfd = b.client_fd; } struct timeval tv = {1, 0}; /* 1 秒超时,留出做心跳和统计的口子 */ int n = select(maxfd + 1, &rset, NULL, NULL, &tv); if (n < 0) { if (errno == EINTR) continue; break; } if (n == 0) continue; /* 超时,本轮无事件 */ if (FD_ISSET(b.listen_fd, &rset)) { int c = net_accept(b.listen_fd); if (c >= 0) { if (b.client_fd >= 0) close(b.client_fd); b.client_fd = c; } } if (b.client_fd >= 0 && FD_ISSET(b.client_fd, &rset)) { ssize_t r = net_recv(b.client_fd, b.net_rx, BUF_SZ); if (r > 0) uart_write_full(b.uart_fd, b.net_rx, (size_t)r); else if (r == 0 || (r < 0 && errno != EAGAIN)) { close(b.client_fd); b.client_fd = -1; /* 对端关闭或出错 */ } } if (FD_ISSET(b.uart_fd, &rset)) { ssize_t r = uart_read(b.uart_fd, b.uart_rx, BUF_SZ); if (r > 0 && b.client_fd >= 0) net_send(b.client_fd, b.uart_rx, (size_t)r); } }maxfd + 1是 select 的第一个参数,必须比集合里最大的 fd 大 1,写成maxfd会导致最后一个 fd 永远不被监视,这是新手最常犯的错之一。net_recv返回 0 表示对端正常关闭(收到 FIN),返回负值且errno != EAGAIN表示出错,两种情况都该关 fd。串口侧读到数据但当前没有客户端,数据就直接丢弃——这是设计选择,避免在没有接收方的时候无限堆积缓冲撑爆内存。
4.3 网络侧收发的边界:EAGAIN、半关闭与 TCP_NODELAY
send在非阻塞 socket 上也可能部分写或者返回EAGAIN,尤其是对端处理慢的时候。net_send内部同样要循环,逻辑和uart_write_full一致,遇到EAGAIN短暂让步。区别在于网络侧更该在意延迟,所以还有两个 socket 选项要设。
int on = 1; setsockopt(cfd, IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on)); /* 关 Nagle */ setsockopt(cfd, SOL_SOCKET, SO_KEEPALIVE, &on, sizeof(on)); /* 开保活探测 */TCP_NODELAY关掉 Nagle 算法。Nagle 会把小包攒起来等 ACK 再发,现场轮询报文往往就几十字节,开启状态下可能平白多出 40ms 延迟,做实时控制的场景完全不能接受。这个选项默认是关的,很多人忘了设,然后抱怨「程序明明没卡,数据就是慢半拍」。
SO_KEEPALIVE的默认探测间隔是两小时,实际意义不大,但配合应用层心跳(主循环里 select 超时那一秒正好可以用来判断最后收包时间)才真正管用。半关闭场景要留意:如果客户端只关写端(shutdown(SHUT_WR)),recv会返回 0,但 socket 本身还能写。透传场景不需要这种复杂语义,一律按「收到 FIN 就关连接」处理,简单不出错。
4.4 断线与重连:如何不把串口搞乱
现场最典型的故障是网线松动或者交换机重启。TCP 连接断了,但串口设备完全不知道,它还在按自己的节奏往串口吐数据。这时候程序的行为决定了恢复速度:如果串口 fd 保持不变,只是client_fd置 -1,那么设备数据会被持续丢弃,新客户端连上后立刻就能续上,中间的空白由上位机协议自己重试补上。
反过来,如果在断线时把串口也关了重开,tcflush一执行,设备正在发的半帧数据就被丢掉,重连后对端可能收到半帧乱码。所以串口 fd 的生命周期和网络连接完全解耦:网线断一百次,串口也只打开一次。这条经验是一次现场事故换来的,当时上位机一直报校验错误,查了半天才发现是断线重连时把串口缓冲清了。
重连后还有个小细节:新客户端连上来的瞬间,串口可能正好有一批积压数据。我的做法是 accept 之后先不清空任何缓冲,直接开始转发,让上位机自己按协议丢弃无效帧。清缓冲看着干净,实际会让对端的两帧数据粘在一起,反而更难处理。
5. 避坑与排查:串口网口转发里最容易翻车的五个点
5.1 串口侧的三类高频故障
现象:程序正常运行,但一按 Ctrl+C 进程就退出,同时串口设备收到一个 0x03 字节。原因是open时漏了O_NOCTTY,串口被当成了本进程的控制终端,行规程把键盘输入的特殊字符转义后写给了设备。解决很简单,open的 flag 补上O_NOCTTY,同时确认cfmakeraw在tcsetattr之前调用过。这个问题在虚拟串口上复现不了,只在真硬件上暴露,所以现场首次部署一定要测。
现象:小报文一切正常,一上大报文就少最后几个字节,且丢失量随时间累积。原因是write到串口返回了部分写,代码只判断> 0就认为写完了。9600 波特率下,1KB 数据要发一秒钟,中间返回个 64 太正常了。解决就是第 3 章那个uart_write_full,循环写直到off == len。顺带说一句,errno == EAGAIN和errno == EIO必须分开处理,前者是缓冲满,后者是设备没了。
现象:跑了几小时,内存没涨,但转发延迟越来越大,重启立刻恢复。原因是串口写的速度长期慢于网络收的速度,数据在主循环里排队但没人管,或者对端 socket 的发送窗口被填满,send一直返回EAGAIN而代码在死循环重试。解决的抓手是两个:给每条方向加一个待发送队列并设上限(比如 64KB),超了就丢最旧的;同时观察stat_net_bytes和stat_uart_bytes的差值,差值持续扩大就说明有一侧堵了。
5.2 网络侧的两类高频故障
现象:拔插一次 USB 转串口,进程还在但数据全断,ps看进程状态正常。原因是设备节点消失后,read返回 -1 且errno是EIO或ENODEV,而代码把它和EAGAIN归成一类,继续在 select 上等一个永远不会再有事件的 fd。解决是在主循环里对串口错误做分类:EAGAIN忽略,EIO/ENODEV走重连流程——关 fd、休眠几百毫秒、按设备路径重新open,成功了继续用,失败了重试。这一条不加,任何一次 USB 松动都得人工重启服务。
现象:客户端重连之后,收到的数据里混着上一轮的残留,或者干脆串台。原因是accept前没有关掉旧client_fd,两个 fd 指向同一个串口,旧连接的对端还在慢慢发数据。解决是accept成功的第一件事就close旧 fd 并置 -1,再赋新值。另外一个隐蔽的变体是端口复用没开,服务重启时绑定失败但程序只打印了一行日志就退出了,运维没注意,以为服务在跑。SO_REUSEADDR和启动失败时明确非零退出码,两个都得有。
提示:现场排查先看统计。串口字节数在涨、网络字节数不涨,问题在客户端或网络;两个都不涨,问题在串口配置或设备本身;两个都在涨但对端报错,多半是波特率或者校验位不一致。这个二分法能省掉大半时间。
6. 二次开发:把写死的转发改成配置驱动的协议网关
6.1 配置化:把命令行参数搬进 conf
命令行参数在单串口场景够用,一旦要同时转发两三路,参数就没法看了。我一般会加一个极简的 key=value 配置文件,用getline逐行读,strtok切分,二三十行代码搞定,不引第三方库。格式长这样:
uart.dev = /dev/ttyUSB0 uart.baud = 115200 net.listen = 0.0.0.0 net.port = 5000 frame.mode = rawframe.mode是留给下一步的开关,raw表示纯透传,改成modbus之类的值就走解析路径。配置文件解析失败的默认值要写死一套能跑的,别让配置缺失直接崩掉——现场改配置的人可能根本不懂代码。
6.2 加一层帧钩子做协议转换
真正的改造点在转发路径上插一个回调。在bridge.c里定义函数指针类型int (*frame_hook)(unsigned char *buf, size_t len, size_t *out_len),raw模式下这个指针是 NULL,直接原样转发;需要做协议转换时注册一个实现,在字节流进入对端之前处理一遍。比如现场常见的场景是把设备侧带 CRC 的二进制帧转成带 ASCII 头的文本帧给上位机,钩子里做校验、拆帧、重新封装,raw模式的代码一行不用改。
要注意钩子的入参是「一次 read 的字节」而不是「一帧」,所以钩子内部必须有攒帧逻辑,返回 0 表示「还没攒够,先别转发」。这个设计把「字节流」和「帧」的边界清晰地放在了钩子里,主循环继续只管搬运,职责不混。
6.3 验证改造是否生效
改完别急着上现场,先在本地验证三个点:透传模式下行字节数必须和改造前完全一致(说明没引入额外拷贝问题);钩子模式下构造一帧带错误校验的数据,看是否被正确丢弃;连续跑两小时,看stat_uart_bytes和stat_net_bytes是否还在同步增长。第三点最容易被跳过,但恰恰最能暴露缓冲泄漏。
我现在的习惯是,每次动转发路径都先用 pty 模拟一对串口跑一遍全链路回归,确认字节级一致再碰真设备。那次在客户现场因为清空串口缓冲导致上位机连报半小时校验错误之后,我就再也不敢跳过这一步了。希望帮到你。
本文还有配套的精品资源,点击获取