Linux系统编程

1.类Unix系统目录

Linux系统中,所见皆文件

根目录下文件夹用途
bin存放二进制可执行文件
boot存放开机启动程度
dev存放设备文件
home存放用户
etc用户信息和系统配置文件
lib库文件
root管理员宿主目录
usr用户资源管理目录

1.1 vim快捷键

按键用途
x删除
u撤销操作
yy复制行
nyy,数字+yy复制光标开始以下几行
dd剪切一行内容
p粘贴
gg跳转到文档头
G跳转到文档尾
v进入可视模式移动光标选中内容,按y复制内容,移动光标到目的地,按p粘贴
/查找内容小n切换到查找下一个结果,N切换到查找上一个结果,查找结果高亮要设置,vim ~/.vimrc
gg=G代码自动化对齐
按两次>>整行右移一个tab,左移同理
数字加按两次>>多少行整行右移一个tab,左移同理
大k进入函数man page
2大k进入man page第二章,函数原型和声明
小o在下一行插入进入编辑模式
大O在上一行插入进入编辑模式
末行模式!加shell指令可以执行shell命令
末行模式下sp+文件名横屏模式,打开两个文件,ctrl+两个w,切换
末行模式下vsp+文件名竖屏模式,打开两个文件,ctrl+两个w,切换

gcc编译过程:
在这里插入图片描述

1.2VIM插件的使用

下载插件管理工具VIM-Plug
终端执行以下命令,安装Vim-Plug:

curl -fLo ~/.vim/autoload/plug.vim --create-dirs https://raw.githubusercontent.com/junegunn/vim-plug/master/plug.vim

在Vim的配置文件~/.vimrc中配置声明插件。列表应该以 call plug#begin(PLUGIN_DIRECTORY) 开始,并以 plug#end() 结束。

call plug#begin('~/.vim/plugged')

/*插件列表*/
Plug 'itchyny/lightline.vim'


call plug#end()

其他的一些在vim命令模式下的指令。

:PlugUpdate   	//更新插件
:PlugInstall 	//下载插件
:PlugClean		//删除插件
:PlugStatus 		//查看插件状态

1.3 gcc,g++,gdb

gcc编译器:即可以编译c代码,也可以编译cpp代码
g++编译器:编译cpp代码
gdb:调试工具,在gcc编译的时候加上-g选项,给编译文件添加调试信息。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

1.4静态库和动态库

1.4.1静态库

静态库的文件命名:libxxx.a,对应Windows中的.lib文件
制作步骤
1、编译为.o文件:gcc file.c -I …/include/ -o libxxx.a
2、将.o文件打包:ar rcs libmycal.a file1.o file2.o flie3.o
3、将头文件与库一起发布
可以用nm命令查看静态库文件。

1.4.2动态库

制作步骤
1、编译与位置无关的代码,生成.o,关键参数-fPIC
2、将.o文件打包:关键参数 -shared
3、将库与头文件一起发布
在这里插入图片描述
32位系统虚拟空间内存举例:
其中text为代码区
在这里插入图片描述
在这里插入图片描述
动态库:
在这里插入图片描述

2.Makefile文件

Makefile的好处,一次编写,终身受益
Makefile的命名规则:
1、makefile
2、Makefile
Makefile三要素:
1、目标文件
2、依赖
3、规则命令
写法:
目标文件:依赖
tab键 规则命令
例如:
第一版Makefile
在这里插入图片描述
如果更改其中一个文件,所有的源码都重新编译
可以考虑编译过程分解,先生成.o文件,然后使用.o文件链接,变成结果。

在这里插入图片描述
第二版makefile
可以定义变量:

ObjFiles = main.o add.o sub.o div.o ...

变量的使用:

$(ObjFiles),$(变量名)

在这里插入图片描述
makefile隐含规则,默认处理第一个目标。
函数:
wildcard 可以进行文件匹配(百搭牌)
patsubst 内容的替换
makefile变量

	$@ 代表目标:
	$^ 代表全部依赖
	$< 第一个依赖
	$? 第一个变化的依赖

在这里插入图片描述

@在规则前,代表不输出该条规则的命令
规则前的“-”,代表该条规则报错,任然继续执行。
%通配符,
在这里插入图片描述

3.gdb调试

使用gdb:编译的时候加 -g参数,(添加调试信息)
启动gdb命令: gdb app(对应可执行程序名)
在gdb启动程序:
1、r(un) 启动
r(run) 指定参数
2、start 分步调试-停留在main函数
3、n(next)下一步指令。
4、s(step)下一条指令,可以进入函数内部,库函数不能进。
5、q(quit)退出gdb
6、设置启动参数set args 10 6 参数
set argc = 4
set argv[1]=2
set argv【2】=3
在调试的中途,设置改变变量的值
7、list 查看文件
8、b(break) 行数—主函数所在文件的行
9、b 函数名 设置断点。
10、b 文件名:行号
11、l(list)查看代码,默认显示10行
l —显示主函数对应的文件
l 文件名:行号
12、删除断点,d(del)编号
13、查看断点,i(info) b,得到编号
14、c(continue)调到下一个断点,
15、p(print)打印变量的值
16、ptype 打印变量的类型。
17、display 显示变量的值,用于追踪,查看变量具体什么时候变化
18、 undisplay 删除显示变量,查看编号,info display
19、 设置条件断点 b line if i==1

3.1core 文件

当无法自动生成core文件时,可以通过命令

sudo service apport stop

来关闭Ubuntu官方收集错误报告的服务程序。重新执行生成便可得到core文件。

设置生成core:

ulimit -c unlimited

取消生成:

core:ulimit -c 0

-c后面的参数,指定core文件的容量大小。
proc目录,存储进程相关的文件
设置core 文件格式:在目录/proc/sys/kernel/core_pattern
文件不能vi,可以用后面的套路 ,在root权限下

echo "/corefile/core-%e-%p-%t">/proc/sys/kernel/core_pattern

在这里插入图片描述

4.linux系统API与库函数接口

在这里插入图片描述
在这里插入图片描述

4.1文件IO

main()函数的形式参数:

int main(int argc, char *argv[],char *envp[])

argc(参数计数)argv[0]程序名称,argv[1]程序第一个参数,以此类推。
envp是一个字符指针数组,其中每个条目都是一个环境变量的字符串。这些字符串通常是以"KEY=VALUE"的格式表示的,表示环境中的各种设置和值。
例如,一个典型的环境变量可能是 PATH=/usr/bin:/bin:/usr/sbin:/sbin,它指示了可执行文件的搜索路径。

open

查看 man 2 open

       int open(const char *pathname, int flags);
       int open(const char *pathname, int flags, mode_t mode);

pathname 文件名,
flags :
在这里插入图片描述

必选项:
o_RDONLY 只读
o_WRONLY 只写
o_RDWR 读写
可选项:
o_APPEND 追加
o_CREAT 创建文件
o_EXCL 与o_CREAT 一起使用,如果文件存在,则报错,mode 权限位,最终(mode &~umask)
o_NONBLOCK 非阻塞
返回值:返回最小的可用文件描述符,失败返回-1,设置errno

close

int close(int fd);
fd open 打开的文件描述符
返回值:成功返回0,失败返回-1。设置errno

read

用法:

ssize_t read(int fd, void *buf, size_t count);

fd,文件描述符
buf,缓冲区
count,缓冲区大小
返回值:失败返回-1,设置errno。成功返回读到的大小。0代表读到文件末尾。非阻塞的情况下,read返回-1,但是此时需要判断errno的值,来判断是否是非阻塞导致的。

write

用法:

ssize_t write(int fd, const void *buf, size_t count);

fd,文件
buf,写缓冲区,
count,缓冲区大小
返回值:成功返回写入的字节数,失败,返回-1,设置errno。0,代表未写入。
应用案例
实现一个cat功能:读文件,输出到标准输出。

  1 #include <stdio.h>                                                                                                                                                                                      
  2 #include <sys/types.h>
  3 #include <sys/stat.h>
  4 #include <fcntl.h>
  5 #include <unistd.h>
  6 
  7 int main (int argc, char *argv[])
  8 {
  9     if(argc != 2)
 10     {
 11         printf("./a.out filename\n");
 12         return -1;
 13     }
 14     int fd = open(argv[1],O_RDWR|O_CREAT,0666);
 15 
 16     write(fd,"helloworld",10);
 17     //文件读写结束,此时位置移动到末尾
 18     //需要移动读写位置
 19     lseek(fd,0,SEEK_SET);
 20     //读,输出到屏幕
 21     char buf[256]={0};
 22     int ret = read(fd,buf,sizeof(buf));
 23     if(ret)
 24     {
 25         write(STDOUT_FILENO,buf,ret);
 26     }
 27     close(fd);
 28     return 0;
 29 }

4.2lseek函数,移动文件的读写位置

       off_t lseek(int fd, off_t offset, int whence);

fd,文件描述符。
offset,偏移量
whence,
SEEK_SET,文件开头位置
SEEK_CUR,当前位置
SEEK_END,结尾
返回值:成功,返回当前位置到开始的长度,失败,返回-1,设置errno。
lseek的作用:
1、移动文件读写位置
2、计算文件大小
3、拓展文件

4.3阻塞和非阻塞的概念

read函数在读设备或者读管道,或者读网络的时候,
输入输出设置对应/dev/tty

fcntl()函数,设置非阻塞。
函数原型

int fcntl(int fd, int cmd, ... /* arg */ );

在这里插入图片描述

案例:

在这里插入图片描述
1、动态库和静态库

  1 #静态库和动态库
  2 #设置变量
  3 cc = gcc
  4 #调用wildcard函数,通配当前目录下所有的.c文件
  5 SrcFiles = $(wildcard *.c)                                                                           
  6 #调用patsubst函数,将所有的.c文件转化为.o文件,第三个参数指定所有的.c文件
  7 ObjFiles1 = $(patsubst %.c,%.o,$(SrcFiles))
  8 %.o:%.c
  9     $(cc) -o $@ -c $< -I ./
 10 
 11 #静态库
 12 mystatlib:$(ObjFiles1)
 13     $(cc) -I ./ -o $@ $(ObjFiles1)
 14 
 15 
 16 
 17 #动态库
 18 ObjFiles2 = $(patsubst %.c,%.o,$(SrcFiles))
 19 %.o:%.c
 20     $(cc) -o $@ -c $< -I ./ -fPIC
 21 
 22 myactivelib:$(ObjFiles2)
 23     $(cc) -I ./ -o $@ $(ObjFiles2) -shared
  24 
 25 
 26 
 27 #清理功能
 28 clean:
 29     rm *.o 
 30     rm mystatlib  

静态库和动态库分别用两个makefile文件编写。
2、自动生成hello和world可执行文件

在Makefile中,一个规则通常用于生成一个目标文件。但是,实际上你可以在一个规则中生成多个文件。以下是一个简单的例子,展示了如何在一个规则中生成两个文件(file1.txtfile2.txt):

all: file1.txt file2.txt

file1.txt file2.txt:
	@echo "Generating two files..."
	@echo "Content for file1" > file1.txt
	@echo "Content for file2" > file2.txt

上面的Makefile当你运行makemake all时,会生成file1.txtfile2.txt两个文件。

需要注意的是,当其中一个文件更新后,如果你再次运行make,Makefile会认为另一个文件也需要重新生成。因为它们共享相同的规则和依赖关系。如果每个文件的生成和更新是独立的,那么最好为每个文件提供独立的规则。
3、实现mycp功能,拷贝一个文件(够大的文件,.avi文件)


 1 #include <stdio.h>
  2 #include <sys/types.h>
  3 #include <sys/stat.h>
  4 #include <fcntl.h>
  5 #include <unistd.h>
  6 
  7 
  8 
  9 int main(int argc, char * argv[])
 10 {
 11     if(argc!=3)
 12     {
 13         printf("./a.out filename\n");
 14         return -1;
 15     }
 16     int fd1 = open(argv[1],O_RDONLY|O_CREAT,0666);
 17     int fd2 = open(argv[2],O_WRONLY|O_CREAT,0666);
 18     char buf[256]={0};
 19     int ret=read(fd1,buf,sizeof(buf));                                                                                                                                                                  
 20     if(ret)
 21     {
 22      //2.lseek,拓展文件
 23         int res = lseek(fd2,1024,SEEK_END);
 24         write(fd2,buf,ret);
 25     }
 26 
 27     close(fd1);
 28     close(fd2);
 29     return 0;
 30 }

补充:进程的虚拟地址空间

在这里插入图片描述
文件描述符是一个非负的整数,表示一个打开的文件。由系统调用返回。在后续操作文件时可以被内核空间应用。

32位系统为例,内存4g,0-3G为用户区,3G-4G为内核区。
内核区包括:虚拟文件系统,内存管理、设备驱动管理、进程管理PCB。
进程创建时会创建文件描述符表,大小1k,自动填写前三位,分别为STDIN FILENO、STDOUT FILENO、STDERR FILENO。之后开辟空间从未屏蔽的小地址开始启用。一个进程最大打开1023个文件
用户区包括:保留段,text(代码段,静态库存放于此)、data(已经初始化的数据)、BSS(未初始化的数据)、heap(堆区,地址递增)、共享库(动态库存放于此)、stack(栈区,地址递减)、cmd,env(命令行参数,环境变量)。

stat/lstat函数的使用

stat函数获得文件信息

int stat(const char *pathname, struct stat *statbuf);

           struct stat {
               dev_t     st_dev;         /* ID of device containing file */
               ino_t     st_ino;         /* Inode number */
               mode_t    st_mode;        /* File type and mode */
               nlink_t   st_nlink;       /* Number of hard links */
               uid_t     st_uid;         /* User ID of owner */
               gid_t     st_gid;         /* Group ID of owner */
               dev_t     st_rdev;        /* Device ID (if special file) */
               off_t     st_size;        /* Total size, in bytes */
               blksize_t st_blksize;     /* Block size for filesystem I/O */
               blkcnt_t  st_blocks;      /* Number of 512B blocks allocated */

               /* Since Linux 2.6, the kernel supports nanosecond
                  precision for the following timestamp fields.
                  For the details before Linux 2.6, see NOTES. */

               struct timespec st_atim;  /* Time of last access */
               struct timespec st_mtim;  /* Time of last modification */
               struct timespec st_ctim;  /* Time of last status change */

           #define st_atime st_atim.tv_sec      /* Backward compatibility */
           #define st_mtime st_mtim.tv_sec
           #define st_ctime st_ctim.tv_sec
           };

在这里插入图片描述

linux系统文件存储的原理。hello.hard 是hello文件的硬链接指向同一个索引节点
通过 ls -i 命令可以查看文件的索引节点。
一个目录对应一个目录项。
在这里插入图片描述
stat函数 st_mode,
在这里插入图片描述
int stat(const char *pathname, struct stat *statbuf);
stat函数参数:
pathname: 文件名
struct stat *statbuf : 传出参数,定义 struct stat sb; &sb
返回值:成功返回0,失败返回-1,设置errno。

lstat与stat的区别,stat具有穿透能力,用stat参看软连接文件时,返回的是源文件的信息。而用lstat查看软连接文件时,返回的是软连接文件的信息。

access函数

判断文件的权限和是否存在

int access(const char *pathname, int mode);

       The mode specifies the accessibility check(s) to be  performed,  and  is  either  the
       value  F_OK, or a mask consisting of the bitwise OR of one or more of R_OK, W_OK, and
       X_OK.  F_OK tests for the existence of the file.  R_OK, W_OK, and X_OK  test  whether
       the file exists and grants read, write, and execute permissions, respectively.

pathname 文件
mode具体权限 (R_OK、W_OK、X_OK、F_OK)
返回值:如果有权限或者文件存在,返回0。失败返回-1,设置errno。

chmod函数

修改文件权限
int chmod(const char *pathname, mode_t mode);
int fchmod(int fd, mode_t mode);

truncate函数

截断文件长度为指定长度,path对应的文件必须存在
int truncate(const char *path, off_t length);
path : 文件名
length 长度如果大于原文件,拓展,小于原文件,截断。
返回值: 成功返回0,失败返回-1,设置errno。

link系列函数

查看man page

int link(const char *oldpath, const char *newpath);
int linkat(int olddirfd, const char *oldpath,
                  int newdirfd, const char *newpath, int flags);

硬连接 :link
软连接:symlink
读取连接:ssize_t readlink(const char * pathname, char *buf ,size_bufsiz);
删除软硬连接:int unlink(const char *pathname);

chown改变用户和组

int chown(const char *pathname, uid_t owner, gid_t group);
pathname:文件
owner:UID
group :组ID。

rename重命名文件

int rename(const char *oldpath, const char *newpath);
oldpath:旧文件名
newpath:新文件名

4.4目录相关函数

获得当前工作路径

char *getcwd(char *buf, size_t size);

chdir改变进程工作目录(进程独有的工作目录)

int chdir(const char *path);

创建目录

int mkdir(const char *pathname, mode_t mode);
mode:权限位。0777,如果目录没有可执行权限,不可进入。

rmdir删除目录

int rmdir(const char *pathname);

opendir打开目录

DIR *opendir(const char *name);

readdir读目录内容

struct dirent *readdir(DIR dirp);
struct dirent {
ino_t d_ino; /
Inode number /
off_t d_off; /
Not an offset; see below /
unsigned short d_reclen; /
Length of this record /
unsigned char d_type; /
Type of file; not supported
by all filesystem types /
char d_name[256]; /
Null-terminated filename */
};
目录项结构体。

errno补充

错误信息,全局变量。
头文件
/usr/include/asm-generic/errno-base.h
/usr/include/asm-generic/errno.h
/usr/include/errno.h

errno输出函数。
char *strerror(int errnum);

dup2复制文件描述符

int dup(int oldfd);
int dup2(int oldfd, int newfd);
在这里插入图片描述
dup2,newfd指向olefd的文件。实现了重定向。关闭newfd对应的文件描述符,将newfd指向olefd对应的文件。
dup(3),新生成一个文件描述符,指向3。实现复制文件描述符。新返回一个文件描述符指向oldfd对应的文件

例子:实现在代码中执行2次prinf(“hello world\n”);一次输出到hello文件中,后一次输出到屏幕上。涉及到文件重定向。

  1 #include <stdio.h>                                                                         
  2 #include <sys/types.h>
  3 #include <sys/stat.h>
  4 #include <fcntl.h>
  5 #include <unistd.h>
  6 int main()
  7 {
  8     //备份现场
  9     int outfd = dup(1);
 10     //先做重定向,将STDOUT指向新创建的文件
 11     int fd = open("world",O_WRONLY|O_CREAT,0666);
 12     dup2(fd,1);
 13     printf("hello world\n");
 14     //需要来一次刷新
 15     fflush(stdout);
 16     //需要恢复1,重新对应标准输出
 17     dup2(outfd,1);
 18     printf("hello world\n");
 19     close(fd);
 20     return 0;
 21 }

5.进程、线程控制相关API

程序:编译好的二进制文件。
进程:运行中的程序。运行一系列指令的过程。
操作系统角度:进程是分配系统资源的基本单位。
区别:
1、程序占用磁盘,不占用系统资源
2、内存占用系统资源
3、一个程序对应多个进程。一个进程对应一个程序。
4、程序没有生命周期,进程有生命周期。
单道和多道程序设计
微观上串行
宏观上并行。

5.1进程控制块PCB

每个进程在内核中都有一个进程控制块(PCB)来维护相关的信息,linux内核的进程控制块是task struct 结构体。
sudo grep -rn “struct task_struct {” /usr/
用上述命令查找task struct结构体定义,查找很慢。

5.2环境变量

写法:
key=val,不能有空格=两边。

getenv函数(获取环境变量)
char *getenv(const char *name);

5.3进程控制

fork()函数

fork() 函数,创建一个新的进程。分叉。
pid_t fork(void);
返回值:失败 -1,设置errno。
成功,每个进程返回一个,父进程返回子进程的ID。子进程返回0。

getpid()函数,getppid()函数,获得父进程ID

   pid_t getpid(void);
   pid_t getppid(void);

案例:

  1 #include <stdio.h>                                                                         
 2 #include <unistd.h>
 3 #include <stdlib.h>
 4 int main()
 5 {
 6     printf("Begin ....\n");
 7     pid_t pid = fork();
 8     if(pid<0)
 9     {
10         perror("fork err");
11         exit(1);
12     }
13     if(pid == 0)
14     {//子进程
15         printf("I am child,pid = %d,ppid = %d\n",getpid(),getppid());
16     }
17     else if (pid > 0)
18     {
19         //父进程的逻辑
20         printf("childpid=%d,self=%d,ppid=%d\n",pid,getpid(),getppid());
21     }
22     printf("End ...\n");
23     return 0;
24 }

查看进程信息
命令 ps,ps aux 和 ps ajx —可以追溯进程之间的血缘关系。
kill 命令,给进程发送一个信号。 kill -l 查看所有信号。kill -9 pid —杀死进程。
创建5个子进程示范代码

循环创建子进程。
在这里插入图片描述

  1 #include <stdio.h>
 2 #include <unistd.h>
 3 #include <stdlib.h>
 4 int main()
 5 {
 6     int n = 5;
 7     int i = 0;
 8     pid_t pid = 0;
 9     for (i=0;i<5;i++)
10     {                                                                                                                                                                                                   
11         pid = fork();
12         if(pid ==0)
13         {
14             //son
15             printf("I am child,pid=%d,ppid=%d\n",getpid(),getppid());
16             break;//子进程退出循环的接口
17         }
18         else if (pid > 0)
19         {
20             //father
21             printf("I am father,pid=%d,ppid=%d\n",getpid(),getppid());
22         }
23     }//子进程i的值为0~4,父进程的值为5
24 
25     sleep(i);
26     if(i<5)
27     {
28         printf("I am child,will dead pid = %d,ppid=%d\n",getpid(),getppid());
29     }
30     else
31     {
32         printf("I am parent,will dead pid=%d,ppid=%d\n",getpid(),getppid());
33     }
34     return 0;
35 }

5.4进程共享

父子进程相同处:全局变量、data、.text、栈、堆、环境变量、用户ID、宿主目录、进程工作目录、信号处理方式…
父子不同处 1、进程ID,2、fork返回值 3、父进程ID 4、进程运行时间 5、闹钟(定时器)6、未决信号集。
父子进程间遵循读时共享写时复制原则。节省内存开销。即使是全局变量,也遵循读时共享写时复制原则。
在这里插入图片描述

execl函数,执行其他程序

       int execl(const char *path, const char *arg, ...  /* (char  *) NULL */);
//执行程序时,使用PATH环境变量,执行的程序可以不用加路径。
      int execlp(const char *file, const char *arg, ...   /* (char  *) NULL */);

file要执行的程序,
arg:参数列表
参数列表第一个需要执行程序名占位。最后需要一个NULL作为结尾,哨兵。
返回值:只有失败才返回。成功直接切换到其它程序执行。
例如:execlp(“ls”,“ls”,“-l”,NULL);

在这里插入图片描述
将当前进程的.text,.data替换为所要加载的程序的.text,.data。然后让进程从新的.text第一条指令开始执行,但进程ID不变,换核不换壳。
在这里插入图片描述

孤儿进程与僵尸进程

孤儿进程:父进程结束,子进程被init进程领养。
僵尸进程:子进程死了,父进程没有回收子进程的资源(PCB)。子进程残留资源存放于内核中,变成(Zombie)进程。
特别注意,僵尸进程是不能使用kill命令清除掉的。因为kill命令只是用来终止进程的,而僵尸进程已经终止。
如何回收僵尸进程:杀死父进程,init领养,负责回收。

子进程回收

回收子进程,知道子进程的死亡原因。
作用:
阻塞等待子进程死亡
回收子进程资源。
查看死亡原因。

       pid_t wait(int *wstatus);

       pid_t waitpid(pid_t pid, int *wstatus, int options);

status,为传出参数。
返回值:成功返回 终止的子进程ID。
失败,返回-1。
子进程的死亡原因:
1、正常死亡 WIFEXITED
如果WIFEXITED为真,使用WEXITSTATUS得到退出状态
2、非正常死亡WIFSIGNALED
如果WIFSIGNALED为真,使用WTERMSIG得到信号。

pid_t waitpid(pid_t pid, int *wstatus, int options);
pid :<-1,-组id。=-1,回收任意子进程。=0,回收和调用进程组ID相同组内的子进程。>0回收指定的pid。
options:=0 与wait相同,也会阻塞。=WNOHANG如果当前没有子进程退出的,会立刻返回。
返回值:如果设置了WNOHANG,那么如果没有子进程退出,返回0。如果有子进程退出,返回退出的PID。
失败返回-1。(没有子进程)

6.进程间的通信

IPC概念:interprocess communication。进程间通信。通过内核提供的缓冲区进行数据交换的机制。
在这里插入图片描述

IPC通信的方式有:

  • pipe 管道 --最简单
  • FIFO 有名管道
  • mmap 文件映射共享IO --速度最快
  • 本地socket 最稳定。
  • 信号 携带信息量最小。
  • 共享内存
  • 消息队列

6.1pipe

只能有血缘关系的进程进行通信。半双工通信。管道要在子进程创建之前创建。因为有两个pipefd。
在这里插入图片描述

int pipe(int pipefd[2]);

pipefd读写文件描述符,0-代表读,1-代表写。
返回值:

  • 失败返回-1
  • 成功返回0。
  1 #include <stdio.h>                                             
  2 #include <unistd.h>
  3 
  4 int main()
  5 {
  6     int fd[2];
  7     pipe(fd);//创建pipe,并指定两个文件描述符
  8     pid_t pid = fork();
  9     if (pid==0)
 10     {//son
 11         write(fd[1],"hello",5);
 12     } else if(pid >0)
 13     {//parent
 14         char buf[12]={0};
 15         int ret = read(fd[0],buf,sizeof(buf));//read返回读到的>
 16         if(ret > 0)
 17         {
 18             write(STDOUT_FILENO,buf,ret);//如果读到数据,通过ST
 19         }
 20     }
 21     return 0;
 22 }

父子进程实现pipe通信,实现ps aux | grep bash功能。

  1 #include <stdio.h>
  2 #include <unistd.h>
  3 
  4 int main()
  5 {
  6     int fd[2];
  7     pipe(fd);
  8     
  9     pid_t pid = fork();
 10 
 11     if(pid==0)
 12     {//son执行ps
 			//关闭读端
 			close(fd[0]);
 13      //1.先重定向
 14      dup2(fd[1],STDOUT_FILENO);//将标准输出重定向到pipe写端
 15      //2.execlp
 16      execlp("ps","ps","aux",NULL);//执行ps命令
 17     }else if (pid >0 )
 18     {
 			//关闭写端
 			close(fd[1]);
 19     //1.重定向,标准输入重定向到pipe读端
 20     dup2(fd[0],STDIN_FILENO);
 21     //2.execlp执行grep bash
 22     execlp("grep","grep","bash",NULL);
 23     }
 24 
 25     return 0;
 26 }  

此段代码会产生僵尸进程。父进程认为还有写端存在,就有可能还有人给写数据,所以等待。子进程写时关闭读端,父进程读时关闭写端。
在这里插入图片描述
读管道:

  • 写端全部关闭 --read读到0,相当于读到文件末尾。
  • 写端没有全部关闭
    • 有数据 --read读到数据
    • 没有数据 – read阻塞,fcntl函数可以更改非阻塞。

写管道:

  • 读端全部关闭 — 产生一个信号SIGPIPE,程序异常终止。
  • 读端未全部关闭
    • 管道已满—write阻塞
    • 管道未满— write正常写入

管道大小查看,ulimit -a 。查看所有系统资源的上限。
long fpathconf(int fd, int name);
计算管道大小 512*8

  • 成功返回管道的大小
  • 失败返回-1,设置errno

管道优点:简单,使用方便。
缺点:只能有血缘关系的进程通信。只能单向通信。双向需要建立两个管道。

练习代码,子进程之间通信

6.2FIFO

FIFO有名管道,实现无血缘关系进程通信。

  • 创建一个管道的伪文件
    • mkfifo myfifo命令创建。
    • 也可以用函数int mkfifo(const char * pathname, mode_t mode);
  • 内核会针对FIFO文件开辟一个缓冲区,操作FIFO文件,可以操作缓冲区,实现进程间通信-实际上就是文件读写。

6.3mmap共享映射区

在这里插入图片描述

       void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);//创建mmap
       /*
 addr :传NULL,
length:映射区的长度
prot:PROT_READ可读/PROT_WRITE可写。
flags:MAP_SHARED共享的,对内存的修改会影响到源文件/MAP_PRIVATE私有的,对内存的修改不会影响到源文件。
fd:文件描述符,open打开一个文件
offset:偏移量。
返回值:成功返回可用的内存首地址,失败返回MAP_FAILED。*/

       int munmap(void *addr, size_t length); //释放映射区
      addr:传mmap的返回值
      length:mmap创建的长度
      返回值:int。

mmap九问:
1、如果更改mem变量的地址,释放的时候munmap,传入mem还能成功吗?
不能。
2、如果对mem越界操作会怎么样?
文件的大小对映射区操作有影响,尽量避免越界。
3、如果文件偏移量随便填个数会怎么样?
offset必须是4k的整数倍。4k==4096。8块*512=4096。
4、如果文件描述符先关闭,对mmap映射有没有影响?
没有影响
5、open的时候,可以新创建一个文件来创建映射区吗?
不可以用大小为0的文件。
6、open文件选择O_WRONLY,可以吗?
不可以,这样会没权限。映射过程隐含一次读操作。
7、当选择MAP_SHARED的时候,open文件选择O_RDONLY,prot可以选择PROT_READ|PROT_WRITE吗?
不可以,因为map_shared修改内存时也会修改文件。而open打开文件没有设置写权限。
8、mmap什么情况下会报错?
9、如果不判断返回值会怎么样?
例子:用mmap实现父子进程之间通信。

1 #include <stdio.h>                                                                  
  2 #include <unistd.h>
  3 #include <sys/types.h>
  4 #include <sys/stat.h>
  5 #include <sys/mman.h>
  6 #include <fcntl.h>
  7 #include <sys/wait.h>
  8 int main()
  9 {
 10     //先创建映射区
 11     int fd = open("mem.txt",O_RDWR);
 12     int *mem = mmap(NULL,4,PROT_READ|PROT_WRITE,MAP_SHARED,fd,0);
 13     if(mem == MAP_FAILED)
 14     {
 15         perror("mmap err");
 16         return -1;
 17     }
 18     //fork出子进程
 19     pid_t pid = fork();
 20     //父子进程交替修改数据
 21     if(pid ==0)
 22     {//child
 23         *mem = 100;
 24         printf("child,*mem=%d\n",*mem);
 25         sleep(3);
 26         printf("child,*mem=%d\n",*mem);
 27     }
 28     else if (pid >0)
 29     {//parent
 30         sleep(1);
 31         printf("parent,*mem=%d\n",*mem);
 32         *mem = 1001;
 33         printf("parent,*mem=%d\n",*mem);
 34         wait(NULL);
 35     }
 36 
 37     munmap(mem,4);
 38     close(fd);
 39     return 0;
 40 }       

匿名映射

我们发现每次创建映射区一定要依赖一个文件才能实现。通过为了建立映射区要open一个temp文件,创建好了再unlink、close掉,比较麻烦、可以直接使用匿名映射来代替。其实Linux系统给我们提供了创建匿名映射区的方法,无需依赖一个文件即可创建映射区。同样需要借助标志位参数flags来指定。使用MAP_ANONYMOUS
如:
int *p = mmap(NULL,4,PROT_READ|PROT_WRITE,MAP_SHARED|MAP_ANONYMOUS,-1,0);

MAP_ANON,MAP_ANONYMOUS这两个宏在有些unix系统中没有。则打开zero文件。
/dev/zero,聚宝盆。可以随意映射。
/dev/null ,无底洞,一般错误信息重定向到这个文件中。
用mmap支持无血缘关系进程通信
如果进程要通信,flags必须设为MAP_SHARED。
在这里插入图片描述
作业:
1、通过命名管道传输数据,进程A 和进程B,进程A将一个文件(MP3)发送给进程B
2、实现多进程拷贝文件。

6.4信号的概念

信号的特点:简单,不能携带大量信息,满足特定条件发生
信号的机制:进程B发送给进程A,内核产生信号,内核处理。
产生信号:
1、按键产生,如:ctrl+c,ctrl+z,ctrl+
2、系统调用产生,如:kill,raise,abort
3、软件条件产生,如:定时器alarm,setitimer
4、硬件异常产生,如:非法访问内存(段错误)、除0(浮点数例外)、内存对齐出错(总线错误)
5、命令产生,如:kill命令

信号的状态:

  • 产生
  • 递达 信号到达并且处理完
  • 未决 信号被阻塞了

信号的默认处理方式:

  • 忽略
  • 执行默认动作
  • 捕获

信号的4要素

  • 编号
  • 事件
  • 名称
  • 默认处理动作
    • 忽略
    • 终止
    • 终止+core
    • 暂停
    • 继续

阻塞信号集
位图
在这里插入图片描述
系统api产生信号
kill函数
int kill(pid_t pid, int sig);

  • pid 大于0,要发送进程ID
  • pid = 0,代表当前调用进程组内所有进程
  • pid =-1 代表有权限发送的所有进程
  • pid 小于0,代表-pid对应的组内所有进程
  • sig 对应的信号

raise函数
int raise (int sig); //给自己发送信号
abort函数:给自己发送一个异常信号
void abort(void);

6.4.1时钟信号

alarm函数
设置定时器(闹钟)。指定seconds之后,内核会给当前进程发送14)SIGALRM信号。进程收到该信号,默认动作终止。
每个进程都有且只有唯一一个定时器
定时给自己发送SIGALRM,几秒后发送信号。返回值,上次闹钟剩余的描述。
特别的,如果传入参数秒数为0,代表取消闹钟。
unsigned int alarm(unsigned int seconds); //alarm发送信号

setitimer 函数
周期性的发送信号。

int setitimer(int which, const struct itimerval *new_value, struct itimerval old_value);
struct itimerval {
struct timeval it_interval; /
Interval for periodic timer /周期性的时间设置
struct timeval it_value; /
Time until next expiration */下次的闹钟时间
};

       struct timeval {
           time_t      tv_sec;         /* seconds */
           suseconds_t tv_usec;        /* microseconds */
       };

which:

  • ITIMER_REAL自然定时法
  • ITIMER_VIRTUAL 计算进程执行时间
  • ITIMER_PROF 进程执行时间+调度时间

6.4.2信号集处理函数

清空信号集
int sigemptyset(sigset_t *set);
填充信号集
int sigfillset(sigset_t *set);
添加某个信号到信号集
int sigaddset(sigset_t *set, int signum);
从集合中删除某个信号
int sigdelset(sigset_t *set, int signum);
判断信号是否为集合中的成员
int sigismember(const sigset_t *set, int signum);
设置阻塞或者解除阻塞信号集
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

  • how
    • SIG_BLOCK 设置阻塞
    • SIG_UNBLOCK解除阻塞
    • SIG_SETMASK设置set为新的阻塞信号集
  • set 传入的信号集
  • oldset 旧的信号集,传出参数。便于恢复上一步状态。

获取未决信号集
int sigpending(sigset_t *set);

  • set传出参数,当前的未决信号集。

6.4.3 信号捕捉

防止进程意外死亡
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);

  • signum 要捕捉的信号
  • handler要执行的捕捉函数指针,函数应该声明void func(int);

注册捕捉函数
struct sigaction {
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
};
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

  • signum捕捉的信号
  • act 传入的动作

信号捕捉特性:
前31个信号,从1开始到31。

  1. 进程正常运行时,默认PCB中有一个信号屏蔽字。设定为,它决定了进程自动屏蔽哪些信号。当注册了某个信号捕捉函数,捕捉到该信号以后,要调用该函数。而该函数有可能执行很长时间,在这期间所屏蔽的信号不由信号屏蔽字来指定。而是用sa_mask来指定。调用完信号处理函数,再恢复为信号屏蔽字。
  2. XXX信号捕捉函数执行期间,XXX信号自动被屏蔽。
  3. 阻塞的常规信号不支持排队,产生多次只记录一个。(后32个实时信号支持排队)

信号处理流程:
在这里插入图片描述

6.4.4 SIGCHLG回收子进程

sigchld信号处理
子进程在暂停或者退出的时候会发送sigchld信号。我们可以通过捕捉SIGCHLG信号来回收子进程。

7 守护进程

进程组:也称之为作业。代表一个或多个进程的几何。每个进程都属于一个进程组。为了简化对多个进程的管理。当父进程创建子进程的时候,默认子进程与父进程属于同一进程组。进程组ID第一个进程ID(组长进程)。所以,组长进程标识:其进程组ID其进程ID。
可以使用kill -SIGKILL -进程组 ID 来讲整个进程组内的所有进程杀死。
会话:是多个进程组组成的。是进程组的更高一级。多个进程组对应一个会话。
创建一个会话需要注意以下5点注意事项:

  1. 调用进程不能是进程组组长。该进程变成新会话首进程
  2. 该进程成为一个新进程组的组成进程
  3. 新会话丢弃原有的控制终端,该会话没有控制终端
  4. 该调用进程是组长进程,则出错返回
  5. 建立新会话时,先调用fork,父进程终止,子进程调用setsid

守护进程
Daemon(精灵)进程,是linux中的后台服务进程。通常独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。一般采用以d结尾的名字
linux后台的一些系统服务进程,没有控制终端,不能直接和用户交互。不受用户登录、注销的影响,一直在运行着,他们都是守护进程。如:预读入缓输出机制的实现;ftp服务器;nfs服务器等。
创建守护进程,最关键的一步是调用setsid函数创建一个新的Session,并成为Session leader。

创建守护进程模型

  1. 创建子进程,父进程退出。所有工作在子进程中进行形式上脱离了控制终端(孤儿进程)
  2. 在子进程中创建新会话。setsid()函数,使子进程完全独立出来,脱离控制,当会长。
  3. 改变当前目录为根目录,一般切换为home目录。chdir()函数防止占用可卸载的文件系统也可以换成其它路径。
  4. 重设文件权限掩码。umask()函数防止继承的文件创建屏蔽字拒绝某些权限。增加守护进程灵活性。
  5. 关闭文件描述符,为了避免浪费资源。继承的打开文件不会用到,浪费系统资源,无法卸载
  6. 开始执行守护进程核心工作。执行核心逻辑。
  7. 守护进程退出处理程序模型

创建一个守护进程:每分钟在$home/log/ 创建一个文件 程序名.时间戳

1 #include <stdio.h>                                                             
  2 #include <unistd.h>
  3 #include <sys/types.h>
  4 #include <sys/stat.h>
  5 #include <fcntl.h>
  6 #include <string.h>
  7 #include <stdlib.h>
  8 #include <signal.h>
  9 #include <sys/time.h>
 10 #include <time.h>
 11 
 12 
 13 #define _FILE_NAME_FORMAT_ "%s/log/mydaemon.%ld"  //定义文件格式化
 14 
 15 void touchfile(int num)
 16 {
 17     char *HomeDir = getenv("HOME");
 18     char strFilename[250]={0};
 19     sprintf(strFilename,_FILE_NAME_FORMAT_,HomeDir,time(NULL));
 20 
 21     int fd = open(strFilename,O_RDWR|O_CREAT,0666);
 22 
 23     if(fd <0)
 24     {
 25         perror("open err");
 26         exit(1);
 27     }
 28     close(fd);
 29 }
 30 
 31 
 32 int main ()
 33 {
 34     //创建子进程,父进程退出
 35     pid_t pid = fork();
 36     if(pid<0)
 37     {
 38         exit(1);
 39     }
 40     //当会长
 41     setsid();
 42     //设置掩码
 43     umask(0);
 44     //切换目录
 45     chdir(getenv("HOME"));//切换到HOME目录
 46     //关闭文件描述符
 47     //close(1),close(2).close(3) 
 48     //执行核心逻辑
 49     struct itimerval myit = {{60,0},{1,0}};
 50     setitimer(ITIMER_REAL,&myit,NULL);
 51     struct sigaction act;
 52     act.sa_flags = 0;
 53     sigemptyset(&act.sa_mask);
 54     act.sa_handler = touchfile;
 55 
 56     sigaction(SIGALRM,&act,NULL);
 57 
 58     while(1)
 59     {
 60         //每隔一分钟在/home/itheima/log 下创建文件
 61         sleep(60);
 62 
 63     }
 64     //退出
 65     return 0;
 66 }                    

拓展了解:通过nohup指令也可以达到守护进程创建的效果。
nohup cmd [> 1.log] &

  • nohup指令会让cmd收不到SIGHUP信号
  • &代表后台运行。

8 多线程

线程是轻量级的进程,本质仍是进程(在linux环境下)。一个进程内部可以有多个线程,默认情况下,一个进程只有一个线程。
进程:独立地址空间,拥有PCB
线程:也有PCB,但没有独立的地址空间(共享)
区别:在于是否共享地址空间。
线程是最小的执行单位,进程是最小的系统资源分配单位。内核实现都是通过clone函数实现的。

在这里插入图片描述
在这里插入图片描述
线程共享资源:

  1. 文件描述符
  2. 每种信号的处理方式
  3. 当前工作目录
  4. 用户ID和组ID
  5. 内存地址空间 {.text/.data/.bss/heap/共享库}

线程非共享资源:

  • 线程ID
  • 处理器线程和栈指针(内核栈)
  • 独立的栈空间(用户空间栈)
  • errno变量
  • 信号屏蔽字
  • 调度优先级

线程的优缺点
优点:

  • 提高程序并发性
  • 占用资源小
  • 通信方便

缺点:

  • 调试、编写困难
  • 库函数,不稳定
  • 对信号支持不好

创建线程
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,void *(*start_routine) (void *), void *arg);

  • thread 线程的id,传出参数
  • attr 代表线程的属性
  • 第三个参数 函数指针, void * func(void *)
  • arg 线程执行函数的参数
  • 返回值
    • 成功返回0
    • 失败返回errno

gcc编译的时候需要加pthread库,gcc name -lpthread
线程退出函数
void pthread_exit(void *retval);
线程退出注意事项:

  • 线程中使用pthread_exit
  • 在线程中使用return(主控线程return代表退出进程)
  • exit代表退出整个进程。

线程的回收函数-阻塞等待回收
int pthread_join(pthread_t thread, void **retval);

  • thread创建的时候传出的第一个参数
  • retval代表的是传出线程的退出信息。

杀死线程
int pthread_cancel(pthread_t thread);
被pthread_cancel杀死的线程,退出状态为PTHREAD_CANCELED
#define PTHREAD_CANCELED((void *) -1)

取消点:是线程检查是否被取消,并按请求进行动作的一个位置。通常是一些系统调用。

实现线程分离
int pthread_detach(pthread_t thread);
此时不需要pthread_join回收资源。
线程的分离状态决定一个线程以什么样的方式来终止自己。
非分离状态:线程的默认属性是非分离状态,这种情况下,原有的线程等待创建的线程结束。只有当pthread_join()函数返回时,创建的线程才算终止,才能释放自己占用的系统资源。
分离状态:分离线程没有被其他的线程所等待,自己运行结束了,线程也就终止了,马上释放系统资源。应该根据自己的需要,选择适合的分离状态。
线程分离状态的函数:
设置线程属性,分离or非分离
int pthread_attr_setdetachstate(pthread_attr_t *attr, int detachstate);
获取线程属性,分离or非分离
int pthread_attr_getdetachstate(const pthread_attr_t *attr, int *detachstate);

NPTL 查看线程库版本

  1. 查看当前pthread库版本 getconf GNU_LIBPTHREAD_VERSION
  2. NPTL实现机制(POSIX),Native POSIX Thread Library
  3. 使用线程库时,gcc指定 -lpthread

线程使用注意事项

  • 主线程退出其他线程不退出,主线程应调用pthread_exit
  • 避免僵尸线程pthread_join,pthread_detach,pthread_create指定分离属性被join线程可能在join函数返回前就释放完自己的所有内存资源,所以不应当返回被回收线程栈中的值。
  • malloc和mmap申请的内存可以被其他线程释放
  • 应避免在多线程模型中调用fork除非,马上exec,子进程中只有调用fork的线程存在,其他线程在子进程中均pthread_exit
  • 信号的复杂语义很难和多线程共存,应避免在多线程中引入信号机制。

创建多少个线程合适?
CPU的核数*2+2。

8.1 线程同步,互斥锁

线程访问同一个共享资源,需要协调同步。同步即协同步调,按预定的先后次序运行。
线程同步,指一个线程发出某一功能调用时,在没有得到结果之前,该调用不返回。同时其他线程为保证数据一致性,不能调用该功能。
解决同步的问题:加锁!

mutex 互斥量
int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);

  • restrict约束该块内存区域对应的数据,只能通过后面的变量进行访问和修改
  • mutex互斥量 --锁
  • attr互斥量的属性,可以不考虑,传NULL
    给共享资源加锁
    int pthread_mutex_lock(pthread_mutex_t *mutex);
  • mutex init 初始化了的锁
  • 如果当前未锁,成功,该线程加锁
  • 如果已经加锁,阻塞等待!
    int pthread_mutex_trylock(pthread_mutex_t *mutex);

int pthread_mutex_unlock(pthread_mutex_t *mutex);

摧毁锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);

死锁:

  • 锁了又锁,自己加了一次锁成功,又加了一次。
  • 交叉锁

互斥量只是建议锁。

8.2 读写锁

读写锁
读写锁的特点:读共享,写独占,写优先级高
读写锁仍然是一把锁,有不同的状态:

  • 未加锁
  • 读锁
  • 写锁

读写锁场景练习:

  • 线程A加写锁成功,线程B请求读锁
    • 线程B阻塞
  • 线程A持有读锁,线程B请求写锁
    • 线程B阻塞
  • 线程A拥有读锁,线程B请求读锁
    • B加锁成功
  • 线程A持有读锁,然后线程B请求写锁,然后线程C请求读锁
    • BC都阻塞。
    • A释放后,B加锁
    • B释放后,C加锁
  • 线程A持有写锁,然后线程B请求读锁,然后线程C请求写锁
    • BC阻塞
    • A释放,C加锁
    • C释放,B加锁

读写锁的使用场景:适合读的线程多。
初始化:
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
销毁读写锁
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
加读锁
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);

8.3 生产者消费者模型

条件变量:可以引起阻塞,并非锁。
在这里插入图片描述
条件变量:
int pthread_cond_destroy(pthread_cond_t *cond);
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

超时等待
int pthread_cond_timedwait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex,const struct timespec *restrict abstime);
abstime 绝对时间,填写的时候time(NULL)+600 ==>设置超时600s。

条件变量阻塞等待
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);

  • 先示范锁mutex
  • 阻塞在cond条件变量上

唤醒至少一个阻塞在条件变量cond上的线程
int pthread_cond_signal(pthread_cond_t *cond);
唤醒阻塞在条件变量cond上的全部线程
int pthread_cond_broadcast(pthread_cond_t *cond);

条件变量的作用:避免无必要的竞争。
生产者消费者模型代码实现:


8.4 信号量

信号量是加强版的互斥量,允许多个线程访问共享资源。
在这里插入图片描述
sem_close
sem_init
sem_post
sem_unlink
sem_destroy
sem_open
sem_timedwait
sem_wait
sem_getvalue
sem_overview
sem_trywait

int sem_init(sem_t *sem, int pshared, unsigned int value);

  • 定义的信号量,传出
  • pshared
    • 0代表线程信号量
    • 非零代表进程信号量
  • value 定义信号量的个数
    Link with -pthread.
    摧毁信号量
    int sem_destroy(sem_t *sem);
    申请信号量,申请成功value–
    int sem_wait(sem_t *sem);
  • 当信号量为0时,阻塞

释放信号量,value++
int sem_post(sem_t *sem);
用信号量机制解决生产者消费者问题:


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值