Linux C++ 开发常用工具
使用g++编译代码
C++编译过程
C++的编译过程主要有四个阶段:预处理、编译、汇编、链接:
- 预处理:主要负责宏定义的替换、条件编译(防止重复包含头文件的宏#ifdef、#ifndef、#else、#endif等)、将include的头文件展开到正文等;
- 编译:负责将源代码转化为汇编代码;
- 汇编:负责将汇编代码转化为可重定位的目标二进制文件;
- 静态重定位:即在程序装入内存的过程中完成,是指在程序开始运行前,程序中的各个地址有关的项均已完成重定位,地址变换通常是在装入时一次完成的,以后不再改变,故称为静态重定位。
- 动态重定位:它不是在程序装入内存时完成的,而是CPU每次访问内存时 由动态地址变换机构(硬件)自动进行把相对地址转换为绝对地址。动态重定位需要软件和硬件相互配合完成。
- 链接:负责将所有的目标文件(二进制目标文件、库文件等)连接起来,进行符号解析和重定位,最后生成可执行文件。静态链接和动态链接两者最大的区别就在于链接的时机不一样。
- 静态链接:将多个源文件产生的目标文件进行链接,从而形成一个可以执行的程序,静态库也可以简单地看成是一组目标文件的集合,即很多目标文件经过压缩打包后形成的一个文件。静态链接是在形成可执行程序前。
- 动态链接:解决静态链接的两个问题,空间浪费和更新困难。动态链接把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。动态链接的进行则是在程序执行时。
g++编译命令
假设有test.cpp文件:
1 |
|
预处理阶段的结果,直接在终端输出,命令为:
1 | g++ -E test.cpp |
编译阶段可以得到汇编代码(.s 文件),可由以下命令直接得到汇编代码文件:
1 | g++ -S test.cpp |
可重定位的目标文件(.o 文件)由以下命令直接得到:
1 | g++ -c test.cpp |
需要得到最终的可执行文件时可使用下面这条命令,需要自己指定目标文件名,下面这条命令最终得到名为 work 的可执行文件:
1 | g++ -o work test.cpp |
两种写法都可以,注意命令中-o后面是生成可执行文件的名字。
在实际开发过程中,往往需要使用 gdb 进行调试,可以在编译时加上-g 选项,如下:
1 | g++ -g -o work test.cpp |
如果在开发过程中加入自己指定使用的动态链接库,比如我们需要使用 pthread 线程库,需要加上 -l 选项。下例中编译器会到 lib
目录下找 libpthread.so
文件:
1 | g++ -o work test.cpp -lpthread |
多文件编译
多个源码文件进行一起编译,例如有三个cpp文件:a.cpp、b.h、b.cpp
1 | // a.cpp |
可以采用以下的编译命令:
1 | g++ -o test a.cpp b.cpp |
使用以下命令运行程序:
1 | ./test |
运行可执行文件
使用 g++ 编译得到的可执行文件,使用如下格式执行:
1 | ./可执行文件名 |
如果程序需要传入参数,则以下述格式执行:
1 | ./可执行文件名 参数1 参数2 |
使用gdb进行调试
安装gdb
安装分为编译安装和 apt
命令安装,因为 Ubuntu 的软件包仓库内置了 gdb,所以可以直接使用命令安装。
1 | sudo apt install gdb |
安装成功后查看gdb版本
1 | gdb -v |
gdb常用调试命令
为了能使用 gdb 调试,在用 g++ 编译时要将 -g 参数加上,然后进行gdb调试:
1 | g++ -g -o test a.cpp b.cpp |
常用的调试命令:
l
:查看代码b 5
:在程序的第 5 行添加断点info break
:查看断点r
:开始运行s
:进入函数内部n
:进入下一步finish
:跳出函数内部c
:运行到下一个断点
最后退出调试,可以直接输入 quit
命令。
编写 makefile 进行自动编译
在大型项目中有大量的源代码文件,不可能每次都逐个敲 g++ 命令来进行编译,而是采用编写 makefile 的方式来进行自动编译,提高效率。
创建makefile
和创建源代码文件一样,可以直接用 vi 编辑器来创建 makefile:
1 | vi makefile |
写好 makefile 内容后,命令模式输入 wq
保存离开即可。
makefile基本格式
一般格式为:
1 | 目标名1:依赖文件1,依赖文件2,依赖文件3 |
其中目标名可以由自己定义,也可以是一个文件的名字;依赖文件就是说要达成这个目标所需要的文件。 仍以前面的“多文件编译”代码为例,可以写出如下的 makefile 文件(注意:makefile 文件中要使用 tab 键,不能使用空格键,否则会报错):
1 | target:a.cpp b.o |
target 依赖于 a.cpp
和 b.o
,而 b.o
依赖于 b.cpp
,因此编译时发现 b.cpp
更新了的话就会先执行后面的命令来更新 b.o
。保存好 makefile 文件之后,我们用命令行输入 make
即可进行自动编译:
快速清理目标文件
有时候我们想要删掉 makefile 产生出来的所有目标文件,如果逐个去删显得过于麻烦,因此我们可以借助 make clean
。仍然是在前面的 makefile 文件中修改,在后面补上一个 clean:
,以及相应的清除命令:
1 | target:a.cpp b.o |
在命令行执行 make clean
就可删掉所有目标文件。
TCP通信
传输层基本概念
TCP/IP 四层参考模型中,从上往下有四种层次:应用层、传输层、网络层、网络接口层,应用层包括 HTTP、FTP、DNS 等协议,而传输层包括 TCP、UDP 两种协议,网络层则包含 IP、ARP 等协议,网络接口层较为底层,一般不是我们研究的对象。其中,传输层是我们在编程开发中较为重要的一层,需要对其中的两种协议尤其是 TCP 理解透彻。
传输层的作用
根本目的:在网络层提供的数据通信服务基础上,实现主机的进程间通信的可靠服务。
主要有以下两个要点:为位于两个主机内部的两个应用进程之间提供通信服务、提供可靠的通信服务。
套接字
“套接字”表示一个 IP 地址与对应的一个端口号。例如,一个 IP 地址为 172.31.75.8 的客户端使用 8050 端口号,那么标识客户端的套接字为“172.31.75.8:8050”。
端口号
端口号为 0-65535 之间的整数,有3种类型:熟知端口号、注册端口号、临时端口号。
熟知端口号:给每种服务器分配确定的全局端口号。每个用户进程都知道相应的服务器进程的熟知端口号,范围为 0-1023,它是统一分配和控制的。
注册端口号:在 IANA 注册的端口号,数值范围为 1024-49151。
临时端口号:客户端程序使用临时端口号,它是运行在客户端上的 TCP/IP 软件随机选取的,范围为 49152-65535。
平时进行网络编程时服务器最好使用注册端口号,而客户端的端口号则是系统随机分配的,即临时端口号。
UDP用户数据协议
UDP协议的特点:
- 无连接的:发送数据之前不需要建立连接,因此减少了开销和发送数据之前的时延。
- 尽最大努力交付:即不保证可靠交付,因此主机不需要维持复杂的连接状态表。
- 面向报文的:UDP 对应用层传递下来的报文,既不合并,也不拆分,而是保留这些报文的边界。UDP 对于应用程序提交的报文,添加头部后就向下提交给网络层。
- 没有拥塞控制:网络出现的拥塞时,UDP 不会使源主机的发送速率降低。这对某些实时应用是很重要的,很适合多媒体通信的要求。
- 支持多对多的交互通信。
UDP的适用场景:
- 适用于少量(几百个字节)的数据。
- 对性能的要求高于对数据完整性的要求,如视频播放、P2P、DNS 等。
- 需要“简短快捷”的数据交换 简单的请求与应答报文交互,如在线游戏。
- 需要多播和广播的应用,源主机以恒定速率发送报文,拥塞发生时允许丢弃部分报文,如本地广播、隧道 VPN。
TCP传输控制协议
TCP协议的特点:
- 面向连接的传输服务。打电话式、会话式通信。
- 面向字节流传输服务(而 UDP 是面向报文)。字节管道、字节按序传输和到达。
- 全双工通信。一个应用进程可以同时接收和发送数据、捎带确认;通信双方都设置有发送和接收缓冲区,应用程序将要发送的数据字节提交给发送缓冲区,实际发送由 TCP 协议控制,接收方收到数据字节后将它存放在接收缓冲区,等待高层应用程序读取。
- 可建立多个并发的 TCP 连接。如 Web 服务器可同时与多个客户端建立的连接会话。
- 可靠传输服务。不丢失数据、保持数据有序、向上层不重复提交数据(通过确认机制、拥塞控制等方式实现), 想象一下 ATM 机转帐应用就需要上述可靠性。
TCP的报文结构图:
重点关注的标志位:
标志 | 说明 |
---|---|
SYN | 当SYN=1,ACK=0,是一个建立连接请求的报文; 当SYN=1,ACK=1,是一个同意建立连接请求的报文; |
ACK | 当ACK=1时,确认序号字段才有意义 |
FIN | FIN=1说明数据发送完毕,请求释放连接 |
RST | RST=1说明出现严重错误,必须释放连接再重新建立连接 |
URG | URG=1说明此报文是紧急数据,要尽快传送出去 |
PSH | PSH=1请求接收方TCP软件将该报文立即推送给应用程序 |
TCP 连接包括连接建立、报文传输、连接释放三个阶段,其中连接建立的三次握手过程较为重要。
建立连接的三次握手过程:
(1)当客户端准备发起一次 TCP 连接,首先向服务器发送第一个“SYN”报文(控制位 SYN=1)。
(2)服务器收到 SYN 报文后,如果同意建立连接,则向客户端发送第二个“SYN+ACK”报文(控制位 SYN=1,ACK=1),该报文表示对第一个 SYN 报文请求的确认。
(3)接收到 SYN+ACK 报文后,客户端发送第三个 ACK 报文,表示对 SYN+ACK 报文的确认。
TCP套接字网络编程
网络编程一般采用C/S架构,即服务器端和客户端。
服务器端
服务器端一般先用 socket 创建一个套接字,然后用 bind 给这个套接字绑定地址(即 ip+端口号),然后调用 listen 把这个套接字置为监听状态,随后调用 accept 函数从已完成连接队列中取出成功建立连接的套接字,以后就在这个新的套接字上调用 send、recv 来发送数据、接收数据,最后调用 close 来断开连接释放资源即可。整个过程如下:
客户端
与服务器不同,客户端并不需要 bind 绑定地址,因为端口号是系统自动分配的,而且客户端也不需要设置监听的套接字,因此也不需要 listen。客户端在用 socket 创建套接字后直接调用 connect 向服务器发起连接即可,connect 函数通知 Linux 内核完成 TCP 三次握手连接,最后把连接的结果作为返回值。成功建立连接后就可以调用 send 和 recv 来发送数据、接收数据,最后调用 close 来断开连接释放资源。整个过程如下:
完整流程
TCP网络编程的完整流程如下:
TCP相关的数据结构
地址结构:
1 | struct sockaddr_in { |
TCP网络编程函数:
socket函数:
1 | int socket( int domain, int type,int protocol) |
bind函数:
1 | int bind(int sockfd,struct sockaddr* my_addr,int addrlen) |
listen函数:
1 | int listen(int sockfd,int backlog) |
accept函数:
1 | int accept(int sockfd, structsockaddr *addr, int *addrlen) |
send函数:
1 | int send(int sockfd, const void * data, int data_len, unsigned int flags) |
recv函数:
1 | int recv(int sockfd, void *buf, intbuf_len,unsigned int flags) |
close函数:
1 | close(int sockfd) |
connect函数:
1 | int connect(int sockfd,structsockaddr *server_addr,int sockaddr_len) |
服务器与客户端连接实例
编写两个程序:一个服务器与一个客户端,用户可以在客户端不断输入信息并发送到服务器终端上显示。
服务器程序编写
编写server.cpp
, 代码如下:
1 | // server.cpp |
客户端程序编写
编写client.cpp
, 代码如下:
1 | // client.cpp |
测试运行
编译两个cpp文件:
1 | g++ -o server server.cpp |
运行服务器程序:
1 | ./server |
另开一个新的终端,运行客户端程序:
1 | ./client |
连接成功,可以在客户端不断输入信息并且在服务器端显示。
多线程并发服务器
上一个实例练习编写的服务器是单线程的,只能为单个客户端服务。而要设计一个聊天室的服务器就必须能够同时为多个客户端服务,因此需要将服务器升级为多线程版本。
进程与线程的基本概念
进程:是程序的一次执行过程,是操作系统资源分配的基本单位。
比如在实例中运行
./server
服务器程序,就会产生一个进程,可以使用ps -ef|grep ./server
命令查看相关进程快照。其中的PID就是该进程的进程号。线程:是任务调度和执行的基本单位,一个进程中可以有多个线程独立运行。线程没有自己独立的地址空间,会与其它属于同一进程的线程一起共享进程的资源,但是每个线程也会有自己的独立的栈和一组寄存器。在 Linux 当中,线程的实现比较特别,会把线程当做进程来实现,即将线程视为一个与其它进程共享资源的进程。
可以使用
ps -T -p XXX(进程号)
命令来查看一个进程的所有线程。
C++11的thread线程库
基本使用
C++11中提供了专门的线程库,可以很方便地进行调用。
使用需要导入头文件:#include<thread>
创建一个新线程来执行 run 函数:
1 | thread t(run); //实例化一个线程对象t,让该线程执行run函数,构造对象后线程就开始执行了 |
假如 run 函数需要传入参数 a 和 b,可以这样构造:
1 | thread t(run,a,b); //实例化一个线程对象t,让该线程执行run函数,传入a和b作为run的参数 |
需要注意的是,传入的函数必须是全局函数或者静态函数,不能是类的普通成员函数。
join 函数会阻塞主线程,直到 join 函数的 thread 对象标识的线程执行完毕为止,join 函数使用方法如下:
1 | t.join(); //调用join后,主线程会一直阻塞,直到子线程的run函数执行完毕 |
但有时候需要主线程在继续完成其它的任务,而不是一直等待子线程结束,这时候我们可以使用 detach 函数。detach 函数会让子线程变为分离状态,主线程不会再阻塞等待子线程结束,而是让系统在子线程结束时自动回收资源。使用的方法如下:
1 | t.detach(); |
代码实例
构建两个线程,同时输出 1-10,实现简单的多线程编程:
新建一个 test_thread.cpp
:
1 | // test_thread.cpp |
然后使用 g++ 进行编译,需要注意的是这里要用上 -l
来链接线程动态库,如下:
1 | g++ -o test_thread test_thread.cpp -lpthread |
实例:多线程并发服务器
具体要求
编写两个程序:一个多线程服务器、一个单线程客户端程序(可和上个实验一样),用一个终端运行服务器程序,多个终端运行客户端程序,让所有客户端发送的信息都能在服务器终端上显示。
- 编写一个服务器类 server,该类可以创建多个线程为多个客户端服务,接收所有客户端发送的消息并打印出来。
- 要编写多个源代码文件:
server.h
头文件给出 server 类声明、server.cpp
给出类方法具体实现、test_server.cpp
中编写主函数创建 server 实例对象并测试。 - 客户端程序可继续使用上个实验的,不用做修改。
- 编写 Makefile 进行自动编译,使用 git 管理版本。
设计思路
因为需要为每个客户端创建一个线程进行服务,所以我们要在每次 accept 取出新连接之后都创建一个线程,这个线程只负责服务这个新的连接,因此我们还要将这个连接对应的套接字描述符传入线程函数中。线程函数不断地调用 recv 接收信息并打印,直到收到客户端发来的 “exit” 或者 recv 返回值小于等于 0 为止。
实现过程
编写 server.h
头文件,给出类的成员变量和成员函数声明:
1 | // server.h |
在 server.cpp
文件中给出函数具体的定义:
1 | // server.cpp |
编写主函数构建实例进行测试,test_server.cpp
文件:
1 | // test_server.cpp |
利用makefile进行自动编译,makefile内容如下:
1 | test_server: test_server.cpp server.cpp server.h |
注意:makefile的缩进不能使用空格,要使用制表符(制表符长度4)。
多线程客户端
实例:多线程客户端
具体要求
将之前的单线程客户端升级为多线程客户端(一个线程用于接收并打印信息、一个线程用于输入并发送信息),为前面实验的多线程服务器添加自动回复客户端的代码,用一个终端运行服务器程序,多个终端运行客户端程序,多个客户端都能发送信息送达服务器并收到服务器的应答,并将应答打印到客户端终端上,当用户在客户端输入 exit 时,要结束两个线程之后再结束客户端进程。
- 编写一个客户端类 client ,有发送线程和接收线程,可以同时发送消息和接收消息。
- 要编写多个源代码文件:client 头文件给出 client 类声明、
client.cpp
给出类方法具体实现、test_client.cpp
中编写主函数创建 client 实例对象并测试。 - 当用户在客户端输入 exit 时,要结束发送线程和接收线程之后才退出主线程。
- 服务器程序要在实验 3 的基础上进行一定修改,能够回复消息。
- 编写 Makefile 进行自动编译,使用 git 管理版本。
设计思路
客户端应先 connect 服务器建立连接,成功连接之后就创建发送线程和接收线程,与服务器类的设计同理,我们需要将发送线程和接收线程的函数设为静态成员函数,发送线程和接收线程中都使用 while(1) 的循环结构,循环终止的条件是用户输入了 exit 或者对端关闭了连接。
实现过程
考虑到 client 类和 server 类会用到许多相同的头文件,因此我们没必要每次都重新写各种头文件,我们可以编写一个 global.h
,在里面写上所有我们需要的头文件(甚至全局变量),让 server.h
和 client.h
都引入这个 global.h
即可,这样通过 global.h
就可以包含所有头文件,没那么容易乱。global.h
文件内容:
1 |
|
在 client.h
头文件中给出 client 类的成员变量和成员函数声明,该类有三个成员变量,同时有构造函数、析构函数、run 函数、发送线程函数、接收线程函数:
1 |
|
在 client.cpp
给出具体的函数定义。构造函数负责初始化服务器 ip 和端口号,析构函数负责关闭套接字描述符:
1 |
|
客户端发送线程的结束很容易,输入 exit 之后 break 即可,但是接收线程无法得知用户是否输入了 exit,因此我们需要进行以下处理:服务器收到 exit 之后断开与客户端的连接,使得客户端接收线程的 recv 返回值为 0,这时再 break 即可退出接收线程。
对服务器代码进行修改,让服务器收到exit后立即close掉套接字描述符,因此需要将sock_arr
改为vector<bool>
类型,初始化的时候就为其分配一定大小的空间,并全部置为 false 表示“未打开”。更改后的 server.h
如下:
1 |
|
在 server.cpp
中开头加入下面这句代码为 sock_arr
完成初始化:
1 | vector<bool> server::sock_arr(10000,false); //将10000个位置都设为false,sock_arr[i]=false表示套接字描述符i未打开(因此不能关闭) |
当然,具体的大小设为 10000 还是其它数字取决于系统能够打开的文件描述符数量,在 Linux 中我们可以使用 ulimit -n
命令来查看和修改文件描述符数量限制。
接下来添加服务器收到 exit 关闭套接字描述符的代码,修改后的 server::RecvMsg
如下:
1 | //注意,前面不用加static! |
最后,我们需要将 server.cpp
的析构函数改为如下形式,来关闭仍处于打开状态的套接字描述符:
1 | server::~server(){ |
至此,对服务器的修改也完成了,我们接下来编写一个 test_client.cpp 文件来测试客户端:
1 |
|
接下来修改 makefile
:
1 | all: test_server.cpp server.cpp server.h test_client.cpp client.cpp client.h global.h |
接下来 make
并且进行测试:
1 | make |