linux ping库函数,在Linux上用C++实现Ping
首先我們在terminal上使用ping命令并用wireshark軟件抓包,看看實現ping命令需要那些協議,以及ping的數據包由那些內容構成。
ping.png
catch_ping.png
用wireshark抓包后,發現ping命令發送的請求報文和收到的應答報文都是ICMP(Internet Control Message Protocol)網際控制報文協議。我們再仔細解析請求報文和應答報文:
request.png
reply.png
發現ICMP報文是裝在IP數據報中的,并且ICMP報文具有以下字段:Type(8位)、Code(8位)、Cheksum(16位)、Identifier(16位)、Sequence(16位)、Timestamp(8位)、Data。
結合以上對ping命令的分析,我們可以想到,實現ping命令,需要用到ICMP協議的相關內容。想要實現對IMCP報文自定義構建,我們還需要用到SOCK_RAW原始套接字的相關內容。在Linux上申請原始套接字需要root權限,但是ping命令可以被普通用戶正常運行,因此我們還需要用到在Linux上以普通用戶運行特權指令的相關內容(在Linux上以普通用戶運行特權指令在我的上一篇博客有詳細的說明,本文不在贅述)。此外,我們可以觀察到ping命令是通過捕獲Ctrl+C指令后才結束的,但是在結束之前,還對整個發送接收情況做了總結,因此,實現Ping命令還需要用到Linux上的信號機制。
ICMP報文
IMCP協議用于在IP主機、路由器之間傳遞控制消息,允許主機或路由器報告差錯情況和提供有關異常情況的報告。ICMP協議不是高層協議(看起來好像是高層協議,因為ICMP報文是裝在IP數據報中,作為其中的數據部分),而是IP層的協議。ICMP報文作為IP層數據報的數據,加上數據報的首部,組成IP數據報發送出去。ICMP報文格式如下圖所示:
ICMP.png
在謝希仁編著的第7版計算機網絡教材中,ICMP報文的格式與上圖相同,但是從上文的抓包情況來看,真正的ICMP報文在16位序列號數據之后,Data數據之前還加入了8位的時間戳數據。
仔細對比上文對請求報文和應答報文的抓包數據,發現除了ICMP報文的序列號(seq)因包而異以外,Type字段也不相同。查閱文獻后知道ICMP報文類型是根據Type字段和Code字段的組合來確定的。Type = 0,Code = 0代表回送請求(Echo Request),Type = 8,Code = 0代表回送應答(Echo Reply),分別對應ping命令的請求報文和應答報文。
對于ICMP報文的校驗和(Checksum)字段的計算,只需要以下幾個步驟:
將校驗和字段置零。
將每兩個字節(16位)相加(二進制求和)直到最后得出結果,若出現最后還剩一個字節繼續與前面結果相加。
(溢出)將高16位與低16位相加,直到高16位為0為止。
將最后的結果(二進制)取反。
用C/C++對ICMP報文數據的構造,可以直接利用ip_icmp.h頭文件中有關ICMP報文的內容進行構造。struct icmp結構如下:
struct icmp
{
uint8_t icmp_type; /* type of message, see below */
uint8_t icmp_code; /* type sub code */
uint16_t icmp_cksum; /* ones complement checksum of struct */
union
{
unsigned char ih_pptr; /* ICMP_PARAMPROB */
struct in_addr ih_gwaddr; /* gateway address */
struct ih_idseq /* echo datagram */
{
uint16_t icd_id;
uint16_t icd_seq;
} ih_idseq;
uint32_t ih_void;
/* ICMP_UNREACH_NEEDFRAG -- Path MTU Discovery (RFC1191) */
struct ih_pmtu
{
uint16_t ipm_void;
uint16_t ipm_nextmtu;
} ih_pmtu;
struct ih_rtradv
{
uint8_t irt_num_addrs;
uint8_t irt_wpa;
uint16_t irt_lifetime;
} ih_rtradv;
} icmp_hun;
#define icmp_pptr icmp_hun.ih_pptr
#define icmp_gwaddr icmp_hun.ih_gwaddr
#define icmp_id icmp_hun.ih_idseq.icd_id
#define icmp_seq icmp_hun.ih_idseq.icd_seq
#define icmp_void icmp_hun.ih_void
#define icmp_pmvoid icmp_hun.ih_pmtu.ipm_void
#define icmp_nextmtu icmp_hun.ih_pmtu.ipm_nextmtu
#define icmp_num_addrs icmp_hun.ih_rtradv.irt_num_addrs
#define icmp_wpa icmp_hun.ih_rtradv.irt_wpa
#define icmp_lifetime icmp_hun.ih_rtradv.irt_lifetime
union
{
struct
{
uint32_t its_otime;
uint32_t its_rtime;
uint32_t its_ttime;
} id_ts;
struct
{
struct ip idi_ip;
/* options and then 64 bits of data */
} id_ip;
struct icmp_ra_addr id_radv;
uint32_t id_mask;
uint8_t id_data[1];
} icmp_dun;
#define icmp_otime icmp_dun.id_ts.its_otime
#define icmp_rtime icmp_dun.id_ts.its_rtime
#define icmp_ttime icmp_dun.id_ts.its_ttime
#define icmp_ip icmp_dun.id_ip.idi_ip
#define icmp_radv icmp_dun.id_radv
#define icmp_mask icmp_dun.id_mask
#define icmp_data icmp_dun.id_data
};
上述結構體中我們只需要關注icmp_type、icmp_code、icmp_cksum、icmp_seq、icmp_id、icmp_data字段即可。我們可以直接在內存中利用指針對icmp結構體的指針指向數據塊進行構造,以達到對整個IMCP報文的構建。例如:
icmp_pointer->icmp_type = ICMP_ECHO;
icmp_pointer->icmp_code = 0;
icmp_pointer->icmp_cksum = 0; //計算校驗和之前先要將校驗位置零
icmp_pointer->icmp_seq = send_pack_num + 1; //用send_pack_num作為ICMP包序列號
icmp_pointer->icmp_id = getpid(); //用進程號作為ICMP包標志
IP數據報
在ping命令中,我們使用recvfrom()函數接收到的回應報文是IP數據報,并且我們需要用到以下字段:
IP報頭長度IHL(Internet Header Length)以4字節為一個單位來記錄IP報頭的長度,是上述IP數據結構的ip_hl變量。
生存時間TTL(Time To Live)以秒為單位,指出IP數據報能在網絡上停留的最長時間,其值由發送方設定,并在經過路由的每一個節點時減一,當該值為0時,數據報將被丟棄,是上述IP數據結構的ip_ttl變量。
使用方法與上述ICMP報文結構體使用方法一致,這里給出struct ip的定義,不在過多說明:
struct ip
{
#if __BYTE_ORDER == __LITTLE_ENDIAN
unsigned int ip_hl:4; /* header length */
unsigned int ip_v:4; /* version */
#endif
#if __BYTE_ORDER == __BIG_ENDIAN
unsigned int ip_v:4; /* version */
unsigned int ip_hl:4; /* header length */
#endif
uint8_t ip_tos; /* type of service */
unsigned short ip_len; /* total length */
unsigned short ip_id; /* identification */
unsigned short ip_off; /* fragment offset field */
#define IP_RF 0x8000 /* reserved fragment flag */
#define IP_DF 0x4000 /* dont fragment flag */
#define IP_MF 0x2000 /* more fragments flag */
#define IP_OFFMASK 0x1fff /* mask for fragmenting bits */
uint8_t ip_ttl; /* time to live */
uint8_t ip_p; /* protocol */
unsigned short ip_sum; /* checksum */
struct in_addr ip_src, ip_dst; /* source and dest address */
};
SOCK_RAW原始套接字
實際上,我們常用的網絡編程都是在應用層的手法操作,也就是大多數程序員接觸到的流式套接字(SOCK_STREAM)和數據包式套接字(SOCK_DGRAM)。而這些數據包都是由系統提供的協議棧實現,用戶只需要填充應用層報文即可,由系統完成底層報文頭的填充并發送。然而Ping命令的實現中需要執行更底層的操作,這個時候就需要使用原始套接字(SOCK_RAW)來實現。
原始套接字(SOCK_RAW)是一種不同于SOCK_STREAM、SOCK_DGRAM的套接字,它實現于系統核心。原始套接字可以實現普通套接字無法處理ICMP、IGMP等網絡報文,原始套接字也可以處理特殊的IPv4報文,此外,利用原始套接字,可以通過IP_HDRINCL套接字選項由用戶構造IP頭。總體來說,原始套接字可以處理普通的網絡報文之外,還可以處理一些特殊協議報文以及操作IP層及其以上的數據。
創建原始套接字的方法如下:
socket(AF_INET, SOCK_RAW, protocol);
這里的重點在于protocol字段,使用原始套接字之后,這個字段就不能簡單的置零了。在頭文件netinet/in.h中定義了系統中該字段目前能取的值,注意:有些系統中不一定實現了netinet/in.h中的所有協議。源代碼的linux/in.h中和netinet/in.h中的內容一樣。我們常見的有IPPROTO_TCP,IPPROTO_UDP和IPPROTO_ICMP。我們可以通過一下方法創建需要的ICMP協議的原始套接字:
struct protoent * protocol; //獲取協議用
//通過協議名稱獲取協議編號
if((protocol = getprotobyname("icmp")) == NULL){
fprintf(stderr, "Get protocol error:%s \n\a", strerror(errno));
exit(1);
}
//創建原始套接字,這里需要root權限,申請完成之后應該降權處理
if((sock_fd = socket(AF_INET, SOCK_RAW, protocol->p_proto)) == -1){
fprintf(stderr, "Greate RAW socket error:%s \n\a", strerror(errno));
exit(1);
}
//降權處理,使該進程的EUID,SUID的值變成RUID的值
setuid(getuid());
用這種方式我就可以得到原始的IP包了,然后就可以自定義IP所承載的具體協議類型,如TCP,UDP或ICMP,并手動對每種承載在IP協議之上的報文進行填充。
Linux上的捕獲Ctrl+C信號
在Linux C/C++程序中,如果程序一直以死循環的狀態運行,以Ctrl+C結束,并且希望在結束時輸出一些統計數據幫助用戶分析,我們就需要用到信號處理機制。通過捕獲到的需要的信號后,執行信號處理函數。為此我們需要用到sigaction()函數,該函數的的功能是檢查或修改與制定信號相關聯的處理動作,其原型為:
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
signum參數用于指定需要捕獲的型號類型,act參數指定新的型號處理方式,oldact參數輸出先前型號的處理方式(如果不為NULL的話)。這個函數的關鍵在于對struct sigaction結構體的設置,我們首先找到該結構體的定義:
struct sigaction
{
/* Signal handler. */
#if defined __USE_POSIX199309 || defined __USE_XOPEN_EXTENDED
union
{
/* Used if SA_SIGINFO is not set. */
__sighandler_t sa_handler;
/* Used if SA_SIGINFO is set. */
void (*sa_sigaction) (int, siginfo_t *, void *);
}
__sigaction_handler;
# define sa_handler __sigaction_handler.sa_handler
# define sa_sigaction __sigaction_handler.sa_sigaction
#else
__sighandler_t sa_handler;
#endif
/* Additional set of signals to be blocked. */
__sigset_t sa_mask;
/* Special flags. */
int sa_flags;
/* Restore handler. */
void (*sa_restorer) (void);
};
注意到有如下幾個字段:
sa_handler 與 sa_sigaction這兩個字段為聯合體,因此只能同時設置一個,這兩個字段的作用都是用來存儲信號處理函數的指針,但是sa_sigaction作為信號處理函數,可以傳入自定義的參數,而sa_handler不行
sa_mask用來設置在處理該信號時暫時將sa_mask 指定的信號集擱置
sa_flags用來設置信號處理的其他相關操作,有下列數值可用,用OR運算組合:
A_NOCLDSTOP:如果參數signum為SIGCHLD,則當子進程暫停時并不會通知父進程
SA_ONESHOT/SA_RESETHAND:當調用新的信號處理函數前,將此信號處理方式改為系統預設的方式
SA_RESTART:被信號中斷的系統調用會自行重啟
SA_NOMASK/SA_NODEFER:在處理此信號未結束前不理會此信號的再次到來
SA_SIGINFO:信號處理函數是帶有三個參數的sa_sigaction
因此,實現ping命令過程中,對Ctrl+C的捕獲及處理的具體實現方法如下:
void SingnalHandler(int signo) { //信號處理函數
//處理過程
...
exit(0);
}
int main(int argc, char * argv[]) {
struct sigaction action; //sigaction結構體
action.sa_handler = SingnalHandler;
sigemptyset(&action.sa_mask);
action.sa_flags = 0;
sigaction(SIGINT,&action,NULL); //SIGINT = 2捕獲Ctrl+C
while(1)
{
//死循環
...
sleep(1);
}
}
最終實現代碼
1、main.cpp
#include
#include "src/ping.h"
Ping * p;
void SingnalHandler(int signo) {
p->statistic();
exit(0);
}
int main(int argc, char * argv[]) {
struct sigaction action;
action.sa_handler = SingnalHandler;
sigemptyset(&action.sa_mask);
action.sa_flags = 0;
sigaction(SIGINT,&action,NULL);
Ping ping(argv[1], 1);
p = &ping;
ping.CreateSocket();
while(1)
{
ping.SendPacket();
ping.RecvPacket();
sleep(1);
}
}
2、ping.h(在src目錄下)
//
// Created by mylord on 2019/9/26.
//
#ifndef MYPING_PING_H
#define MYPING_PING_H
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#define PACK_SIZE 32 //最小的ICMP數據包大小,8字節的ICMP包頭,16字節的DATA,其中DATA是timeval結構體
class Ping {
private:
std::string input_domain; //用來存儲通過main函數的參數傳入的域名或者ip
std::string backup_ip; //通過輸入的域名或者ip轉化成為的ip備份
int sock_fd;
int max_wait_time; //最大等待時間
int send_pack_num; //發送的數據包數量
int recv_pack_num; //收到的數據包數量
int lost_pack_num; //丟失的數據包數量
struct sockaddr_in send_addr; //發送到目標的套接字結構體
struct sockaddr_in recv_addr; //接受來自目標的套接字結構體
char send_pack[PACK_SIZE]; //用于保存發送的ICMP包
char recv_pack[PACK_SIZE + 20]; //用于保存接收的ICMP包
struct timeval first_send_time; //第一次發送ICMP數據包時的UNIX時間戳
struct timeval recv_time; //接收ICMP數據包時的UNIX時間戳
double min_time;
double max_time;
double sum_time;
int GeneratePacket();
int ResolvePakcet(int pack_szie);
unsigned short CalculateCksum(unsigned short * send_pack, int pack_size);
public:
Ping(const char * ip, int max_wait_time);
~Ping();
void CreateSocket();
void SendPacket();
void RecvPacket();
void statistic();
};
#endif //MYPING_PING_H
3、ping.cpp(在src目錄下)
//
// Created by mylord on 2019/9/26.
//
#include "ping.h"
Ping::Ping(const char * ip, int max_wait_time){
this->input_domain = ip;
this->max_wait_time = max_wait_time < 3 ? max_wait_time : 3;
this->send_pack_num = 0;
this->recv_pack_num = 0;
this->lost_pack_num = 0;
this->min_time = 0;
this->max_time = 0;
this->sum_time = 0;
}
Ping::~Ping() {
if(close(sock_fd) == -1) {
fprintf(stderr, "Close socket error:%s \n\a", strerror(errno));
exit(1);
}
}
void Ping::CreateSocket(){
struct protoent * protocol; //獲取協議用
unsigned long in_addr; //用來保存網絡字節序的二進制地址
struct hostent host_info, * host_pointer; //用于gethostbyname_r存放IP信息
char buff[2048]; //gethostbyname_r函數臨時的緩沖區,用來存儲過程中的各種信息
int errnop = 0; //gethostbyname_r函數存儲錯誤碼
//通過協議名稱獲取協議編號
if((protocol = getprotobyname("icmp")) == NULL){
fprintf(stderr, "Get protocol error:%s \n\a", strerror(errno));
exit(1);
}
//創建原始套接字,這里需要root權限,申請完成之后應該降權處理
if((sock_fd = socket(AF_INET, SOCK_RAW, protocol->p_proto)) == -1){
fprintf(stderr, "Greate RAW socket error:%s \n\a", strerror(errno));
exit(1);
}
//降權處理,使該進程的EUID,SUID的值變成RUID的值
setuid(getuid());
//設置send_addr結構體
send_addr.sin_family = AF_INET;
//判斷用戶輸入的點分十進制的ip地址還是域名,如果是域名則將其轉化為ip地址,并備份
//inet_addr()將一個點分十進制的IP轉換成一個長整數型數
if((in_addr = inet_addr(input_domain.c_str())) == INADDR_NONE){
//輸入的不是點分十進制的ip地址
if(gethostbyname_r(input_domain.c_str(), &host_info, buff, sizeof(buff), &host_pointer, &errnop)){
//非法域名
fprintf(stderr, "Get host by name error:%s \n\a", strerror(errno));
exit(1);
} else{
//輸入的是域名
this->send_addr.sin_addr = *((struct in_addr *)host_pointer->h_addr);
}
} else{
//輸入的是點分十進制的地址
this->send_addr.sin_addr.s_addr = in_addr;
}
//將ip地址備份下來
this->backup_ip = inet_ntoa(send_addr.sin_addr);
printf("PING %s (%s) %d(%d) bytes of data.\n", input_domain.c_str(),
backup_ip.c_str(), PACK_SIZE - 8, PACK_SIZE + 20);
gettimeofday(&first_send_time, NULL);
}
unsigned short Ping::CalculateCksum(unsigned short * send_pack, int pack_size){
int check_sum = 0; //校驗和
int nleft = pack_size; //還未計算校驗和的數據長度
unsigned short * p = send_pack; //用來做臨時指針
unsigned short temp; //用來處理字節長度為奇數的情況
while(nleft > 1){
check_sum += *p++; //check_sum先加以后,p的指針才向后移
nleft -= 2;
}
//奇數個長度
if(nleft == 1){
//利用char類型是8個字節,將剩下的一個字節壓入unsigned short(16字節)的高八位
*(unsigned char *)&temp = *(unsigned char *)p;
check_sum += temp;
}
check_sum = (check_sum >> 16) + (check_sum & 0xffff); //將之前計算結果的高16位和低16位相加
check_sum += (check_sum >> 16); //防止上一步也出現溢出
temp = ~check_sum; //temp是最后的校驗和
return temp;
}
int Ping::GeneratePacket()
{
int pack_size;
struct icmp * icmp_pointer;
struct timeval * time_pointer;
//將發送的char[]類型的send_pack直接強制轉化為icmp結構體類型,方便修改數據
icmp_pointer = (struct icmp *)send_pack;
//type為echo類型且code為0代表回顯應答(ping應答)
icmp_pointer->icmp_type = ICMP_ECHO;
icmp_pointer->icmp_code = 0;
icmp_pointer->icmp_cksum = 0; //計算校驗和之前先要將校驗位置0
icmp_pointer->icmp_seq = send_pack_num + 1; //用send_pack_num作為ICMP包序列號
icmp_pointer->icmp_id = getpid(); //用進程號作為ICMP包標志
pack_size = PACK_SIZE;
//將icmp結構體中的數據字段直接強制類型轉化為timeval類型,方便將Unix時間戳賦值給icmp_data
time_pointer = (struct timeval *)icmp_pointer->icmp_data;
gettimeofday(time_pointer, NULL);
icmp_pointer->icmp_cksum = CalculateCksum((unsigned short *)send_pack, pack_size);
return pack_size;
}
void Ping::SendPacket() {
int pack_size = GeneratePacket();
if((sendto(sock_fd, send_pack, pack_size, 0, (const struct sockaddr *)&send_addr, sizeof(send_addr))) < 0){
fprintf(stderr, "Sendto error:%s \n\a", strerror(errno));
exit(1);
}
this->send_pack_num++;
}
//要對收到的IP數據包去IP報頭操作,校驗ICMP,提取時間戳
int Ping::ResolvePakcet(int pack_size) {
int icmp_len, ip_header_len;
struct icmp * icmp_pointer;
struct ip * ip_pointer = (struct ip *)recv_pack;
double rtt;
struct timeval * time_send;
ip_header_len = ip_pointer->ip_hl << 2; //ip報頭長度=ip報頭的長度標志乘4
icmp_pointer = (struct icmp *)(recv_pack + ip_header_len); //pIcmp指向的是ICMP頭部,因此要跳過IP頭部數據
icmp_len = pack_size - ip_header_len; //ICMP報頭及ICMP數據報的總長度
//收到的ICMP包長度小于報頭
if(icmp_len < 8) {
printf("received ICMP pack lenth:%d(%d) is error!\n", pack_size, icmp_len);
lost_pack_num++;
return -1;
}
if((icmp_pointer->icmp_type == ICMP_ECHOREPLY) &&
(backup_ip == inet_ntoa(recv_addr.sin_addr)) &&
(icmp_pointer->icmp_id == getpid())){
time_send = (struct timeval *)icmp_pointer->icmp_data;
if((recv_time.tv_usec -= time_send->tv_usec) < 0) {
--recv_time.tv_sec;
recv_time.tv_usec += 10000000;
}
rtt = (recv_time.tv_sec - time_send->tv_sec) * 1000 + (double)recv_time.tv_usec / 1000.0;
if(rtt > (double)max_wait_time * 1000)
rtt = max_time;
if(min_time == 0 | rtt < min_time)
min_time = rtt;
if(rtt > max_time)
max_time = rtt;
sum_time += rtt;
printf("%d byte from %s : icmp_seq=%u ttl=%d time=%.1fms\n",
icmp_len,
inet_ntoa(recv_addr.sin_addr),
icmp_pointer->icmp_seq,
ip_pointer->ip_ttl,
rtt);
recv_pack_num++;
} else{
printf("throw away the old package %d\tbyte from %s\ticmp_seq=%u\ticmp_id=%u\tpid=%d\n",
icmp_len, inet_ntoa(recv_addr.sin_addr), icmp_pointer->icmp_seq,
icmp_pointer->icmp_id, getpid());
return -1;
}
}
void Ping::RecvPacket() {
int recv_size, fromlen;
fromlen = sizeof(struct sockaddr);
while(recv_pack_num + lost_pack_num < send_pack_num) {
fd_set fds;
FD_ZERO(&fds); //每次循環都必須清空FD_Set
FD_SET(sock_fd, &fds); //將sock_fd加入集合
int maxfd = sock_fd + 1;
struct timeval timeout;
timeout.tv_sec = this->max_wait_time;
timeout.tv_usec = 0;
//使用select實現非阻塞IO
int n = select(maxfd, NULL, &fds, NULL, &timeout);
switch(n) {
case -1:
fprintf(stderr, "Select error:%s \n\a", strerror(errno));
exit(1);
case 0:
printf("select time out, lost packet!\n");
lost_pack_num++;
break;
default:
//判斷sock_fd是否還在集合中
if(FD_ISSET(sock_fd, &fds)) {
//還在集合中則說明收到了回顯的數據包
if((recv_size = recvfrom(sock_fd, recv_pack, sizeof(recv_pack),
0, (struct sockaddr *)&recv_addr, (socklen_t *)&fromlen)) < 0) {
fprintf(stderr, "packet error(size:%d):%s \n\a", recv_size, strerror(errno));
lost_pack_num++;
} else{
//收到了可能合適的數據包
gettimeofday(&recv_time, NULL);
ResolvePakcet(recv_size);
}
}
break;
}
}
}
void Ping::statistic() {
double total_time;
struct timeval final_time;
gettimeofday(&final_time, NULL);
if((final_time.tv_usec -= first_send_time.tv_usec) < 0) {
--final_time.tv_sec;
final_time.tv_usec += 10000000;
}
total_time = (final_time.tv_sec - first_send_time.tv_sec) * 1000 + (double)final_time.tv_usec / 1000.0;
printf("\n--- %s ping statistics ---\n",input_domain.c_str());
printf("%d packets transmitted, %d received, %.0f%% packet loss, time %.0f ms\n",
send_pack_num, recv_pack_num, (double)(send_pack_num - recv_pack_num) / (double)send_pack_num,
total_time);
printf("rtt min/avg/max = %.3f/%.3f/%.3f ms\n", min_time, (double)sum_time / recv_pack_num, max_time);
}
4、CMakeLists.txt
cmake_minimum_required(VERSION 3.10)
project(MyPing)
set(CMAKE_CXX_STANDARD 14)
add_executable(MyPing main.cpp src/ping.cpp src/ping.h)
5、編譯及運行過程
cmake .
sudo make
sudo chmod u+s MyPing
./MyPing www.baidu.com
總結
以上是生活随笔為你收集整理的linux ping库函数,在Linux上用C++实现Ping的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 电脑小白必备的52个专业术语,有必要了解
- 下一篇: 按键精灵找图并点击图片中间