HOME
BLOG
C系统编程
May 22 2023

练气期

os就是操作系统,,linux,windows

文件io标准库函数和系统调用关系

fopen c的库函数,open系统调用,用fopen,就是fopen(3)库函数,用fopen底层会调open系统调用,调的open其实就是open(2)

man帮助的顺序

标准库函数都是在用户空间调用的

fopen调用open,open打开指定文件,返回一个文件描述符(一个int类型的编号),(分配一个FILE结构体,其中包含该文件的描述符,io缓冲区和当前读写位置等信息)返回增FILE结构体的地址

fd=FILE*. 这个FILE * 被经常称作 句柄或者上下文。意思就是我们只需要操作句柄,但是结果表现的是我们对于内部文件做了操作


然后fgetc(fd) ,会发现这些库函数都是操作的对应的数据结构,而不是真正的文件,而真正的文件都是通过系统调用操作的

fgetc也是调read

fgets是先通过FILE*参数找到文件的描述符,io缓冲区和当前读写位置,判断能否从io缓冲区读到下一个字符,如果能读到就直接返回该字符。否则调用read,把文件描述符传进去,让内核读取改文件数据到io缓冲区,然后返回下一个字符

比如fgets要读A,,但是io缓冲区空的,所以就会调read,让内核去文件读,读到缓冲区,假如读了ABC到缓冲区,然后返回缓冲区里的A就行,读写位置移动。假如在调一个fgets,就会直接从缓冲区读了,而调不到read系统调用了。。

read只是缓冲区空了,才让内核去文件读东西到缓冲区


fputc会调write给文件写

同样也是看io缓冲区是否有空间,有空间了,就写到缓冲区,,当缓冲区满了,才会调用write,让内核把缓冲区的内容写进文件


fclose ->close

如果调用fclose,缓冲区还有数据,那么调fclose底层会调write把缓冲区的内容写到文件,这样用户空间才是空的,然后才调用close关闭文件,释放FILE结构体和缓冲区

缓冲区就好像菜鸟驿站一样,存到一定量了或者触发某些条件了才去发快递,在驿站收快递同理

缓冲区分为:全缓冲,行缓冲,无缓冲

全缓冲就是只有缓冲区填满了才会触发系统调用

行缓冲就是当出现换行\n或者填满 都会触发系统调用

无缓冲就是有东西就调用系统调用,像stderr输出错误的就是无缓冲,stdout输出屏幕就是行缓冲

注意linux下一切皆文件,open能打开任意的东西

open

close

比如打开三个文件abc,先打开a 打开b,然后关闭a ,再打开c ,最后关闭b,关闭c

打印他们的文件描述符fd会发现是3 4 3,,为啥?因为0 1 2是标准输入输出和错误。最新的文件描述符只能从后面开始,a是3 ,b是4。当a关闭之后,再open,此时3号描述符是空的了,且返回当前最小的fd给文件,那么c的fd就会被分配给3

read

read读的就是内核里面真正的文件位置,c标准库调的读返回的是那个缓冲区buff的位置,如果读到文件尾就提前返回了,即使没有读够count个字节;从终端设备读,是以行读的,遇到换行就读完退出

小问题tips: shell是个程序,一直在监听stdin。。比如执行程序 ./a.out ,shell就退居幕后, ./a.out这个程序出来监听stdin,,我们输入20个字符,但是./a.out这个假如只读10个字节,读完之后a.out退出,,那么shell提到前面,继续监听stdin,那么他就会读到stdin输入流剩下那十个字节,把他当成命令去执行。。这样就会出问题

#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<stdio.h>
#include<errno.h>
#include<stdlib.h>
#include<unistd.h>
int main(int argc,int *argv[])
{
    if(argc<2){							   个人 组 其他
        printf("--help");                  读写执行
        return -1;                         rwx
    }                                      rw- r-- r--
   // int fd=open(argv[1],O_WRONLY|O_CREAT,0644);
    int fd=open(argv[1],O_RDONLY);
    if(fd<0){
        perror("OPEN err");
        return -1;
    }
    else{
        perror("xxx");
    }
    char buff[1024];
    ssize_t n=read(fd,buff,10);
    
    int fc=open("b.txt",O_WRONLY|O_CREAT,0644);
    write(fc,buff,10);
    close(fd);
    close(fc);
    return 0;
}
    ./main  a.txt
argv    0   1

非阻塞的读写

fcntl

用来改变一个已打开的文件的属性,而不必重新open文件,可以设置读写,追加,非阻塞等标志,也能去给文件上锁,上了之后就是说这个文件这一段时间只能我这个进程去用。

fcntl功能很多

#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<stdio.h>
#include<errno.h>
#include<stdlib.h>
#include<unistd.h>
#include<fcntl.h>
int main()
{
    int flags=0;
    //给标准输入设置非阻塞权限
    if((flags=fcntl(STDIN_FILENO,F_GETFL))<0)
    {
        perror("get flags err\n");
        exit(-1);
    }
    flags|=O_NONBLOCK;
    if((flags=fcntl(STDIN_FILENO,F_SETFL,flags))<0)
    {
        perror("set flags err\n");
        exit(-1);
    }

     '''读写数据
    return 0;
}

流的重定向 标准输入流> <标准输出流

mmap内存映射

addr如果为空,内核会自己在进程地址空间选择合适的地址建立映射,我们可以给addr一个地址,这个地址只不过是建议的,内核会从给的这个地址开始往上找合适的位置,真正的映射的首地址可以通过mmap返回值拿到

linux 可以用 -txl -tc 文件,查看文件里面每个字符的ASCII值的16进制

#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<stdio.h>
#include<errno.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/mman.h>
int main(int argc,int *argv[])
{
    if(argc<2){							
        printf("--help");                  
        return -1;                      
    }                                      
   // int fd=open(argv[1],O_WRDONLY|O_CREAT,0644);
    int fd=open(argv[1],O_RDWR);
    if(fd<0){
        perror("OPEN err");
        return -1;
    }
    else{
        perror("xxx");
    }
    char *p=mmap(NULL,10,PROT_WRITE,MAP_SHARED,fd,0);
    for(int i=0;i<5;++i)
    {
        p[i]='a';
    }
    munmap(p,10);
    close(fd);

    return 0;
}

ext2文件系统

树状结构

linux早期的文件系统

inode表要理解,,很重要. 一个文件除了数据之外的别的标识都在存在inode表里面。inode就是对整个文件的管理信息表 ,,注意每个文件的文件名没有存在自己的inode或者数据块中,,而是存在此文件当前的上级目录中,目录中数据块会存当前目录下的文件夹,文件的名字

stat

stat系统调用或者liunx封装的stat命令,可以查当前文件的状态,inode,blocks,大小,链接数,权限等

链接

软链接就是真的重新创建了一个文件,类似windows的快捷方式,,硬链接只是给文件起了别名,给硬链接数+1了,

遍历目录数据块的记录

VFS虚拟文件系统

dup和dup2做重定向

oldfd去覆盖newfd,执行完该命令后,newfd如果在使用,就关闭,完了newfd就指向oldfd指向的那个文件

'''头文件'''

int main()
{
    int fd,save_fd;
    if((fd=open("test.txt",O_RDWR))<0)
    {
        perror("open err\n");
        exit(1);
    }
    
    save_fd=dup(1);
    dup2(fd,1);
      close(fd);
    char*buf="nihao";
    write(1,buf,strlen(buf));
    dup2(save_fd,1);
    write(1,buf,strlen(buf));
    close(save_fd);
    return 0;
}

上面dup和dup2做个文件重定向的逻辑解释

神通期

fork

复制进程,,调用失败返回-1

调用成功:父亲fork成功拿到儿子的pid,,儿子被成功复制出来之后儿子拿到的是0

#include<unistd.h>
pid_t fork(void)
    

不同的地方:pid,内存锁没有被继承,

fork之后就会进到内核态,子进程复制出来之后,切回用户态,此时父进程和子进程两个此时都是处于就绪状态,所以不加控制的话,他们的运行顺序是由cpu去调度

两句话说清孤儿和僵尸进程

bash起来,运行a程序(这个a程序其实就是bash的儿子),此时bash就退到后台去,cpu把a程序占着,此时a程序fork出一个a1子进程,当a程序先结束,会产生一个尸体(退出码),bash睡的好好的,被尸体这个东西给惊醒了,那么bash就会起来去给a程序收尸,此时bash其实就是可以继续正常去执行新的程序了,但此时 a1这个对于bash来说的这个孙子进程还没结束,但是他的父亲a已经死了,此时就称a1这个进程是孤儿进程,,那等a1执行完了谁给他收尸呢?其实是被一个叫init进程去收尸了,所以此时a1他的父进程就成了init,其实当他的老父亲a先于他结束的那一刻,a1的父亲就成了init进程,init就是操作系统中开天辟地中的老祖宗,第一个进程。

set follow-fork-mode parent
set follow-fork-mode child
多进程去gdb,模式设置,看是去跟随哪个进程

exec族

操作系统多个程序就怎么跑的,就是通过fork+exec

exec就是用新程序去替换你刚刚新fork出来的那个子进程

如果exec调用成功,就会加载新的程序从启动代码处开始执行,就不在返回了

试试这个demo代码:

#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
    printf("path value=[%s]\n",getenv("PATH"));
    setenv("PATH","hell",1);  //在当前程序里面把path环境变量改成hell了
                          //相当于你子进程去改了环境变量
                        //但你的终端的好比父进程,他的path其实是没有被改的,所以不用担心        完了你在终端 $PATH查看一下就知道了
    extern char** environ;
    for(int i=0;environ[i]!=NULL;++i)
    {
        printf("%s\n",environ[i]); #把你操作系统的环境变量全打印出来了
    }
    return 0;
}

wait,waitpid

一个进程终止是会关闭所有的文件描述符,释放在用户空间分配的内存,但他的pcb还保存的,内核在pcb中还保存一些信息,如果是正常终止则保存着退出状态,是异常退出则保存着导致该进程终止的信号是哪个

用wait/waitpid可以获取这些信息,然后彻底清除这个进程

查看状态信息,Z+就代表当前进程是个僵尸进程

kill-9没法杀已经是僵尸的进程,,可以杀僵尸进程他爹,这样僵尸就会成孤儿,被init收尸

进程间通信 IPC

匿名管道

pipe,,信息一个时刻只能单向流通,父子进程用,fd[0]读端,fd[1]写端

#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>

int main()
{
    int fd[2];
    char buf[20];
    if(pipe(fd)<0)
    {
        perror("pipe");
        exit(1);
    }
    pid_t pid=fork();
    if(pid<0)
    {
        perror("fork");
        exit(1);
    }
    else if(pid=0)
    {
        close(fd[0]);
        write(fd[1],"hello pipe\n",11);
        wait(NULL);
    }
    else{
        close(fd[1]);
        int n=read(fd[0],buf,20);
        write(1,buf,n);//写到输出到屏幕上
    }
    return 0;
}

管道popen和pclose,,两个集成化的函数

这两个函数实现的操作是:创建一个管道,fork一个子进程,关闭管道的不使用端,exec一个cmd命令,等待命令终止

FILE *popen(const char*commmand,const char*type);
返回,若成功则为文件指针,若出错,则为NULL

int pclose(FILE*stream)
 返回command的终止状态,出错返回-1

popen打开管道,fork,调用exec执行command的并且返回一个标准文件io的指针

type为r ,将文件指针连接到cmd的标准输出,,w连接到cmd的标准输入

{
    FILE*fp=popen("cat ./hexo.txt","r");  //打印到屏幕上
        
    
    FILE*fp=popen("./hexo","w");  //运行hexo这个程序,往屏幕上输出
    
}

命名管道FIFO

不拘泥于父子进程

共享内存,最快的ipc

通过ftok把内核中共享内存那块结构key获取,然后用shmget把key传进去,拿到这块共享内存id,通过shmat把共享内存和用户空间进行映射,后面用户通过映射的地址进行操作

消息队列

整体流程

信号

kill -l 可以列出系统支持的信号

  1. ulimit -a 命令用于显示当前 shell 进程的所有限制。而 ulimit -c 命令用于设置或显示当前 shell 进程的 core 文件大小限制。
  • Core 文件是在程序运行发生错误时操作系统自动生成的一种文件,并记录了程序错误发生时的内存状态、寄存器内容等信息。通过分析 Core 文件可以定位并修复程序错误。

  • 程序段错误(Segmentation Fault)通常是指程序在访问无效的内存地址或者尝试对只读内存进行写操作时,由操作系统抛出的一种信号,导致程序异常退出或者崩溃。这通常是由于程序代码编写不当、内存泄漏等问题导致的。

  • Core Dump(核心转储),通常指在程序运行出现严重错误导致程序终止时,操作系统会自动保存程序在此时的内存状态和在运行过程中的其他数据到磁盘上,以 Core 文件的形式保存,以便程序员或开发人员在出现问题时进行程序调试。

    发生core dump其实程序错误就是生成core文件了,一般core文件的大小默认的是0,0这个大小的话,系统默认是不会产生core文件的,如果想产生core文件就得修改大小,用ulimit -a,查看一下系统现在这个core的size是多少,完了用ulimit -c去设置一下core文件大小,这样程序错误发生core dump才能生成core 文件,我们才能根据core文件去定位错误,出现core文件了,用gdb +你出错的程序+生成的core文件 可以去调式 ,操作比如:gdb进去用bt查看调用堆栈情况

三个函数越来越具体了

abort很强,无论如何abort都会让你这个程序终止,属于异常终止,程序结束后会产生core dump

有关exit

在Linux/Unix系统中,exit命令可以让进程(包括shell进程)退出,并返回一个退出状态码。一般情况下,该命令有以下几种情况:

  1. exit 0:表示正常退出,返回状态码为0。该状态码表示程序成功执行完成。
  2. exit 1-255:表示异常退出,返回状态码为1到255。此时,状态码的含义由程序员自行定义,不同状态码代表不同的出错原因。
  3. exit -n:用于在脚本中设置退出状态码。其中,-n表示状态码,表示程序在执行完当前命令后,退出并返回该状态码。
  4. exit:表示不带参数的exit命令,这时会直接退出当前进程,但不会返回状态码,这种情况也可以被认为是正常退出,默认会返回状态码0。

需要注意的是,exit命令只是让当前进程退出,并不会影响其他进程。如果想要让某个进程的子进程也一并退出,可以使用kill命令来发送SIGTERM(或其他信号)信号,以达到强制退出的目的。

alarm

alarm函数可以设置一个闹钟,告诉内核在多少秒之后给当前进程法sigalrm信号

一般使用该函数来进行定时任务的处理。alarm只有一个定时器,比如我刚开始设置5s,然后睡眠3s,这时候还有2s才会发信号退出,此时我在设置alarm为5s,定时器就又成剩的2s变成5s,而不会2s加5s变成7s

阻塞信号

如果在进程解除对某个信号的阻塞之前这种信号产生过多次,怎么处理的?

linux:是这么搞的,对于常规信号34号之前的,如果产生多次,只计算一次。比如你按了好多次ctrl+c,他操作系统都会认为是一次,等不阻塞了,ctrl+c就成功退出了。

对于实时信号34号之后的,在递达之前产生多次,会依次放在一个队列里面

阻塞信号集也叫做当前进程的信号屏蔽字

对于pending和block这俩信号集,0就是通过,1不通过;可以通过信号集的函数,去设置我们设置的信号集合是否阻塞的状态,如果设置了阻塞,相当于这个信号你就收不到了,对应block位置成1

示例:如上图,我们可以把(1,2,3,4)号信号,这个信号集去全都通过信号集函数去设置阻塞的状态1,把他们加到信号屏蔽字中,之后我们对于这四个信号就收不到了

不能理解我说的这句话,看看gpt理解的:

相关的流程函数如下:

信号屏蔽字是一种用于控制进程对信号的接收和处理的机制,可以通过设置信号屏蔽字来屏蔽或解除屏蔽某些信号。在Linux/Unix系统中,可以使用sigprocmask函数和相关函数对信号屏蔽字进行操作。

  1. sigprocmask函数

sigprocmask函数用于设置或获取当前进程的信号屏蔽字,其原型如下:

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

其中,参数how用于指定屏蔽字需要进行的操作类型,可以选择以下值:

  • SIG_BLOCK:加入(阻塞)信号集
  • SIG_UNBLOCK:移除(解除阻塞)信号集
  • SIG_SETMASK:设置(替换)信号集

参数set用于指定需要设置的信号集,参数oldset用于记录原先设置的信号集。

sigprocmask函数的使用方法如下:

(1)创建一个信号集

使用sigemptyset函数清空一个信号集,并使用sigaddset函数向其中加入需要设置的信号。

(2)设置信号屏蔽字

使用sigprocmask函数设置进程的信号屏蔽字,将信号集中的相应信号加入或移除出屏蔽字。

  1. sigaddset和sigemptyset函数

sigaddset函数用于向信号集中添加一个信号,其原型如下:

int sigaddset(sigset_t *set, int signum);

其中,参数set为要操作的信号集,参数signum为需要添加的信号编号。

sigemptyset函数用于清空一个信号集,其原型如下:

int sigemptyset(sigset_t *set);

其中,参数set为要操作的信号集。

使用这两个函数的步骤如下:

(1)定义一个空信号集:

sigset_t myset;
sigemptyset(&myset);

(2)向信号集中加入要操作的信号:

sigaddset(&myset, SIGUSR1);
  1. sigismember函数

sigismember函数用于查询一个信号是否在信号集中,其原型如下:

int sigismember(const sigset_t *set, int signum);

其中,参数set为要操作的信号集,参数signum为需要查询的信号编号。如果查询到信号在信号集中,则返回1;否则返回0。

2.sigpending用来检查操作未决信号集

int sigpending(sigset_t *set);

做一个简单示例,加入阻塞信号集,判定在不在未决信号集里面

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
void handler(int sig)
{
    printf("Signal %d received.\n", sig);
}
int main()
{
    // 设置信号处理程序
    signal(SIGINT, handler);

    // 创建一个信号集
    sigset_t set;

    // 初始化信号集,将所有信号都清除
    sigemptyset(&set);

    // 将SIGINT信号添加到信号集中
    sigaddset(&set, SIGINT);

    // 阻塞SIGINT信号
    sigprocmask(SIG_BLOCK, &set, NULL);

    // 检查是否有未决的信号
    if (sigpending(&set) == 0)
    {
        if (sigismember(&set, SIGINT))
        {
            printf("There is a pending SIGINT signal.\n");
        }
    }
    // 解除阻塞
    sigprocmask(SIG_UNBLOCK, &set, NULL);
    return 0;
}

这些函数能够允许我们对信号屏蔽字进行正确有效的操作,有助于我们控制程序对信号的处理。

捕捉信号

我们自己自定义相应的信号处理函数

对于我们自定义的信号处理,操作系统总是在内核回退到用户空间之前这个时刻去处理,这样的效率会高,如果每次遇到信号我都要从用户空间跑到内核去处理,再回退,这样效率很低。流程如下图:

pause函数可以使调用进程挂起,直到信号到达

sigsuspend函数包含了pause的挂起等待功能,同时解决了时序竞争的问题,在对时序有严格要求的场合要用sigsupend

C进阶