Unix related things

这是一篇2017年左右的记录, 仅用作分享

  • 在shell内能干的事,我们都可以比较简单地通过系统调用实现.
  • `称为反引号,^称为脱字符,常用来表示CTRL
  • windows的系统调用是不开放的,windows下只能直接使用windows.h里的windows API.
  • /dev目录下的设备是供用于程序直接使用的,主要由block,char,pipe,socket类型
    • 并不是所有设备都能映射为这种形式
  • /sys/device/目录称为sysfs,他下面存放了所有设备的信息.(不能直接从/dev获得任何设备信息)
    • udevadm info --query=all --name="/dev/sda1"可以用于查询/dev下某个设备对应的sysfs路径

      权限系统

  • 权限系统由两部分组成
    • 文件属性:用于标注文件owner,所属组,以及权限的设定(默认只有owner和root可以修改权限设置)
    • 运行时检查系统:每个进程都有一个"有效ID",该"有效ID"将按UGO的顺序与文件的权限设置进行匹配.
      • 若进程有效ID == 文件的owner ID: 则按U测试权限
      • 否则,若进程有效ID == 文件的group ID:责任按G测试权限
      • 否则,按O测试权限
  • 进程有三组ID:实际ID,有效ID,save_ID. 当进行权限裁定时,是按照有效ID进行的.
    • 进程的实际ID就是caller的ID,caller ID 本质上在登陆时决定.
    • 进程的save_ID是父进程的有效ID
    • 有效ID默认情况和父进程的有效ID相同(对用户而言,父进程一般是shell),如果可执行文件设置了suid,那么就按可执行文件的来。
    • 用户可以主动将save_id及实际ID的任意一个设置为有效ID
  • 权限系统相关的主要是安全问题,在应用和开发中一般不必特别关注.
  • 关于特殊权限
    • suid:chmod u+s当文件可执行时,执行进程的有效ID是文件的owner,而非caller.
    • sgid:chmod g+s在该目录中创建的文件默认都和目录属于同一个组,而非属于创建者的组.
    • sticky:chmod o+t对目录拥有写入权限的用户仅可以写用户自身的文件,无法写其他用户的文件.
    • 特殊权限是通过隐藏的3位二进制值实现的,这三位位于UGO前,换言之,一个文件的真实权限依12位存储,但是由于特殊权限不常用且功能特定,因此默认不显示.

典型目录结构

  • `/lib目录中一般只有共享库
  • /bin/sbin存放的是系统必须的可执行文件,这些程序一般都对应了系统调用.
    • /usr/bin/usr/sbin做的事一般都能在用户空间完成
    • /usr/local主要由管理员安装一些所有用户都要用到的软件
  • /var主要存储日志,/var/tmp不会被自动清空,/tmp会被自动清空
  • /etc主要存放配置文件
  • /opt里常用于存储一些公用的第三方软件
  • /boot内存放启动相关的文件,/vmlinuz/boot/vmlinuz内存放内核,可加载的内核模块在/lib/modules
  • /sys/devices/则用于展示设备路径,设备路径可以唯一的标识硬件,在linux中以目录的形式存在,目录中的文件可以提供设备信息
  • /dev下一般是经驱动映射出来的典型IO设备.

重定向与命令替换

  • func1 | func2 :将func1的标准输出输入到func2的标准输入
  • func2 $(func1):将func1的stdout作为命令行参数输入到func2
    • 和"func2 func1"效果相同
    • 和"func1 | xargs func2" 效果相同

进程

  • 0 号进程一般是OS内核,内核态. 1 号进程一般是init,在用户态运行;他们都是以uid 0执行
  • fork进程时,子进程并不是和父进程一毛一样,例如,对于线程的拷贝是未定义的,一般可以认为fork之后,只有调用fork的线程被保留了.
    • 多线程下的fork一定要先查下相关资料.
  • exit() 一般是一个库函数,_exit()一般对应了一个系统调用,前者会额外做一些Runtime相关的清理工作.
  • 进程的终止状态有两种:正常终止,异常终止,可以在waitpid()之后检查.
    • 子进程终止时,会向父进程发送一个SIGCHILD信号.
    • 在UNIX中,进程终止后进入僵尸模式,直到它的终止状态被父进程取走,内核才开始执行销毁.
    • 如果父进程先于子进程被销毁,那么所有子进程都会被过继给init进程,init将会及时的取走终止状态,销毁僵尸进程.
  • 父进程调用wait()waitpid()是非常重要的,因为只有这样才能获得子进程的终止状态.
    • 子进程暂停时,这两个函数也会返回相应的状态.
  • "终止状态值"和"退出状态值"是不同的,后者是指进程正常终止时通过returnexit()返回的指,退出状态值用不同的API获得.
  • exec并不会完全清除状态,仍有部分状态是从当前进程继承的.
    • 一般情况下,exec时会关闭所有的文件描述符,这种行为可以用fcntl控制.
  • exec时,路径path可以为脚本文件,此时OS会根据脚本第一行的指示启动解释器,然后执行解释器 path arg1 .... argn.
  • 一般而言,只有execve()(或fexecve())是系统调用,其他都是库函数.
  • argv[0] 通常对被调用进程没有特殊意义. 在bash中,如果shell是被init启动的,那么它的argv[0]被设置为/,这个bash会据此认为自己是"登录shell",之后就会加载一系列配置文件,非登录shell则不会这么做.
  • root用户设置id后,会直接修改有效ID/真实ID/save_id, 之后就再也回不到root了.
  • 如果对安全敏感,那么只要父进程的save_id有特权时,就一定要关注exec及system等启动新进程的操作.
  • longjmp 和 setjmp: 主要关注的是在回跳的是时候,是如何恢复现场的,有的实现中,是完整的栈展开会退到现场,有的则仅仅是jmp过去,再恢复现场的寄存器.
    • C++的异常一般就是通过setjmp和longjmp实现的, 在catch的位置会setjmp, 而在throw时,实际就是longjmp到最近set的位置.

文件IO

  • 文件IO时,lseek可以超出当前文件的末尾,以用于延长文件,此时可能会在文件末尾创造空洞.
    • 文件空洞仅仅是在存储上可以优化,它就是正常文件的一部分,可以认为空洞里面全是0.
  • 系统调用提供的IO都是"无缓冲"的,是指没有用户层的软件缓冲,OS内的软缓冲以及物理上的高速缓冲体系仍然是工作的.
    • OS一般会提供系统调用刷新缓冲.,unix中是fsync,sync和fdadasync.
      struct FileDescriptor{
      //文件描述符
      FileDsecriptor(){
      file= GetNewFile();
      }
      ...
      File * file
      } 
  • OS管理File对象;进程维护FileDescriptor对象;文件可以被重复打开;联系这三个属性,就能理解OS的行为
    • 进程中的int FD值相当于索引号.
    • 单个进程可以重复打开文件,获得多个描述符,可以用于同时IO文件的多个位置.
    • 尽管fork()会拷贝属于进程的描述符,但是里面的File * file指针仅仅是浅拷贝,父进程和子进程将使用同一个File * file
    • 多个进程可以重复打开文件,获得各自的描述符,可以用于同时IO文件.
    • 通过文件共享数据时,一定要考虑缓冲/锁/数据同步的问题.
  • O_APPEND文件标志是多进程安全的,它保证每次IO前都会先定位到文件末端.
    • 因此,除非必要,否则不要使用O_APPEND,这样你将失去在文件中任意位置写入的权利.
  • FileDescriptor的拷贝.
    • int new_fd=dup(old_fd);//在目前的最小控线位置创建old_fd的拷贝.
    • int new_fd=dup2(old_fd,at_fd);// 在at_fd索引位置创建old_Fd的拷贝
    • int new_fd=open("/dev/fd/0"),等价于dup(0),有的系统还提供了/dev/stdin,/dev/stdout等,用于辅助我们打开常用的设备
    • 拷贝的主要用途是重定向,例如dup2(fd,0)就能把标准输入覆盖.
  • fcntl提供了对文件描述符的相关操作,主要用于动态的修改参数
    • 例如,F_SETFD用于设置描述符,F_SETFL用于设置fd对应的文件表项目.
  • 标准IO库是基于fd开发的,但是标准库的fopen并不能打开所有文件,在POSIX中,可以先获得fd,再通过fdopen()创建对应的fp
  • 标准IO库的软件缓冲有三种模式:
    • 全缓冲:缓冲区满了才刷新
    • 无缓冲:禁用软件缓冲.
    • 行缓冲:在字符模式中,若遇到\n则刷新缓冲
    • ISO C中: stdin和stdout在不指向交互设备时,才可能是全缓冲的;stderr是无缓冲的.
    • 对每一个FILE * fp,都可以通过setvbuf()控制它的缓冲行为(改变缓冲尺寸,禁用缓冲等)
  • 注意: 标准IO库中,读和写共享一个缓冲空间,如果fflush了,那么所有没有被用户读走的数据都将被丢弃.
  • 字符流读取中的ungetc()一般仅操作软件缓冲,所以可逆/不可逆的流都可以使用.
  • 文件打开模式
    • +仅仅是保证了读写权限,不会改变行为,例如w+始终会截断文件.
  • IO支持"记录锁",可以对文件的部分字节加锁,保证并行安全,这种锁是跨进程OS级别的.(apue 14章)
    • 在进程内,还可以使用flockfile(FILE * fd)系列API,用于保护fd的锁
    • 记录锁分为"建议锁"和"强制锁","建议锁"要求所有进程都按带锁的模式编程,"强制锁"则由OS维护锁状态,任何IO函数都会校验锁状态.
  • 低速IO(阻塞式IO):可能永久阻塞进程的IO函数称为低速IO. 相反的概念为"非阻塞IO"
  • IO批量查询(IO多路转接):pool,select以及pselect
    • 批量查询功能可以一次查询多个fd的状态,从而判断其是否可读/可写/异常. 相比于手动轮询每个fd的状态, 这组API在等待期间可以让出CPU资源,效率更高.
      • readvwritev
    • readv:连续读取一个fd,把其中的值顺序存储到多个buf中
    • writev:顺序从多个buf中读值,再写入fd中
    • 这里面的多个buf形成了一个指针数组,也就是vector

进程间通信

为了可扩展性, 原则上进程间通信最好仅使用TCP Socket.(这另一方面还保证了不会出现跨进程的对象,自然也就避免了跨进程内存的同步问题)
从设计上说,如果你只共享少量的数据,拷贝开销很小,那么为什么要用共享内存呢? 但是如果你需要共享大量的数据(此时往往还需要知道内存的布局),为什么不用多线程呢?

管道

  • pipe():
    • 仅能单向传输,双向传输需要使用两个管道.
    • 在子进程和父进程之间可以建立"管道",体现出来就是父进程有一个fd1,子进程有一个fd2,一边read,另一边write来流式传递数据.
    • 管道两端有2个fd,从一边写入的只能由另一边读出
  • system执行新函数时,只能获得返回状态,而通常我们需要的是stdout,此时可以用popen,popen可以视为对pipe + fork + exec的封装
    • FILE * popen(const char * cmdstring, char * type)
    • cmdstring将产生一个新进程,该进程执行sh -c cmdstring
    • type可以是r或者w,这意味着返回的FILE * fd是可读或者可写的.
      • w模式启动后: 当前程序对fd的写入将对应子进程的标准输入.
      • r模式启动后: 当前进程对fd的读取将对应子进程的标准输出.
    • popen常用于拦截标准IO,用于做预处理/后处理
    • 特别的popen的父进程/子进程可以是我们自己编写的,这也就实现了一种单向通信的方法.
  • 具名管道FIFO
    • 现通过mkfifo/mknod在文件系统中创建一个FIFO类型的文件.
    • 程序按照自己的需求open这个文件
    • FIFO的fd既可以读,又可以写,按照队列的模式传输数据
    • FIFO有读端和写端的概念,只有当读端/写端都有fd打开时,对FIFO的读写才不会阻塞.
  • 在PIPE和FIFO中,PIPE_BUF用于描述原子传输的个数,当写端调用write时,只要尺寸小于PIPE_BUF,就可以一次写完.

XSI IPC

  • 包括:进程间消息队列,进程间信号量,进程间共享存储区. 尽管如此,一般只有"共享存储区"被认为是实用的
  • XSI IPC的共享存储区不需要文件作为中介,直接操作原始内存,不存在来回拷贝的开销,是性能最好的方案.在共享存储区中实现锁/信号量等对象,就能保证跨进程IO的安全.
  • XSI IPC的实现是和文件系统解耦的,所以不能通过文件系统进行管理,为此,文件IO的诸多特性就不能使用了
  • XSI IPC没有引用计数,必须由用户销毁.

POSIX 信号量

  • POSIX信号量是对 IPC 信号量的高性能改进.
    • 默认的POSIX信号量是未命名的,就是典型的信号量,用于线程间同步.(如果放在共享存储区,也可以跨进程同步)
    • 可以为信号量取名字,由于一些系统会使用文件系统实现,所以信号量的名字总应该看起来是合法的绝对路径,以保证可移植性,例如/mysem

套接字(Socket)

网络基础

  • TCP和UDP的下一层都是IP层,所以从本质上来说,TCP数据包和UDP数据包的区别并不大.
  • UDP协议可以认为是对IP层的简单封装,从而让用户能直接传输数据.在UDP中,将(source_ip,source_port,target_ip,target_port,data)封好之后,就可以直接交给IP层传输了;最终,目标机器的对应端口会收到数据包,然后目标再执行对应的解包操作.
    • 逻辑传输单位为"包": 发送方和接收方处理的都是整个包,需要手动解析包内容.
    • 无序: 由于网络拥塞等原因,UDP包到达的顺序和发出的顺序是不确定的.如果需要依赖包顺序,就需要在包的数据段中打上时间戳.
    • 不可靠: 由于网络拥塞等原因,UDP包可能会丢失,但是发送方/接收方无法直接检查是否丢失,为了保证可靠性,一方面需要在包中打上包索引这样的标记,另一方面需要在确定包丢失后,手动重新发包.
    • 无连接:从工作流中来说,发送方只负责把UDP包发送给IP层, 而不关注目标地址是否存在,也不关心数据是否到达. 所以说,UDP是无连接的.
    • 负载大: UDP包是需要无条件转发的,目标也需要无条件接收, 客户接收后,再主动决定是否需要丢弃;对网络和客户而言,都引入了一定的负载,容易引起网络拥塞问题.
  • TCP是可靠的,有序的,有连接的,且有拥塞控制.具体而言,TCP相当于在UDP的基础上实现了一个warpper, 一方面解决了一些问题,另一方面则引入了一些额外开销. 由于TCP的长期应用, 硬件层/OS层围绕TCP进行了很多优化,除非UDP的优势十分明显,一般情况都应该使用TCP.(事实上, 完全可以在UDP的基础上实现类似TCP的功能,但是何苦呢?)
    • 传输逻辑为"字节流", 对于发送方和接收方而言,处理更为便利.
    • 有连接: TCP中, 在初始阶段会有一个建立连接的过程, 这一过程的主要目的是确认连接双方彼此都存在. 在确认双方存在后,就认为连接建立了.(这种"连接"仅仅确认彼此存在,是比较弱的,在一些应用中,连接阶段过后,常常会互发心跳包,重复确认彼此存在)
    • 有序: TCP数据包的顺序由协议维护,保证数据的顺序
    • 可靠: 如果出现了丢包,TCP会自动重新发包
    • 负载小: OS的拥塞控制和硬件的QoS可以有效地控制TCP包在网络中的传输.

基础

  • 相比管道/共享存储区, socket的主要优势是可以使用网络协议,从而实现网络级别的进程间通信.socket当然也可以用于本机进程间的通信, 此时,与管道/共享内存相比,使用socket更容易在将来移植到网络环境中.

  • int socket(int domain,int type,int protocl)

    • 返回值: sockfd,该socket描述符暂时为空 (指向一个空的socket)
    • domain: 网络域,一般是INET(IPv4), INET6(IPv6),或UNIX,域不同时,使用的网络地址格式不同, 所以又常称为address family.
      • 如果是在本机进程之间使用socket,那么可以使用UNIX,其编程模型更为简单
    • type: 传输方式,一般是DGRAM,采用带有目标地址的定长数据包; 或 STREAM,流式数据传输
    • protocl: 使用0即可,采用与domain+type匹配的默认协议

    UNIX中,将行为模式typeprotocol解除了绑定,这是为了保留弹性,例如以后除了一个新的FAST_TCP,它的行为和TCP一致,但是速度更快,此时的行为模式是可以不变的,但是协议可以直接换掉. 在目前,一般情况下,可以认为DGRAM就是UDP, STREAM就是TCP

  • socket的典型工作模式为: host1_ <-> host1_sockfd <—->host2_sockfd <-> host2.

    • socket传输数据一定需要一对socket_fd,从一个fd写入的只能由另一个fd读出.
  • socketfd默认是空的, 需要通过bind来将其绑定到具体的socket file (如果对应的socket file不存在,bind 会创建一个新的), 具体而言,socket addresssocket file是一一对应的, 域内通过socket address来区别不同的socket实体

    • socket_file 由OS管理,用户不能直接open socketfile,只能通过bind后的sockfd来read/write 访问sockfile.
    • 对于INET和INET6,socket address的物理地址是IP:PORT型的
    • 对于客户端,一般不必显式的调用bind,可以直接由OS分配.
    • 不同协议对于socket file的使用有不同的规则, 例如, TCP中,如果一个sockfd已经listen了socket file,那么一般就不允许其他fd再listen这个socket file了 (并行安全相关)
  • sockfd既可以close,又可以shutdown, close只减少sockfile上的引用计数,shutdown则用于禁用在sockfile上的IO.

  • 套接字中的数据传输/网络地址都是大端存储的,传输时一般要处理端序问题.

套接字网络地址sockaddr

  • sockaddr实际上是一个"基类指针", 当你得到一个struct sockaddr * address时,首先需要获得位于头部的family值, 然后再根据它来进行一次指针类型的转换.

    • 一般而言,用户是不需要解析sockaddr的,只需要在用的时候传走就行了.
    • 对于INET和INET6,sockaddr里存储的主要是IP地址和端口号
      typedef unsigned short  sa_family_t;// AF_INET, AF_INET6, UNIX
      #define RESERVE_SIZE
      struct sockaddr{
      sa_family_t sa_family;
      char sa_data[RESERVE_SIZE] ;
      };
      struct sockaddr_in{
      sa_family_t sa_family;
      uint16_t port; 
      uint8_t addr[4];
      uint8_t zero[8]; //reserved
      };
      struct sockaddr_in6{
      sa_family_t sa_family;
      uint16_t port;
      uint32_t flowinfo;
      uint8_t addr[16];
      uint32_t scope_id;
      };
  • 开发中,主要通过getaddrinfo()来获取需要的sockaddr, 一方面可以改善可移植性,另一方面getaddrinfo确实很便利.

    int getaddrinfo(const char * host,
                const char * service,
                const addrinfo * hint,
                addrinfo * ret_info)
  • getaddrinfo()

    • host和service形式很多,典型的如("localhost","ftp",...)("www.zzz.com","9000",...)都可以
    • host和service至少需要提供一个,此时,将返回所有满足要求的ret_info
    • 返回的ret_info是一个链表的头,该链表通过freeaddrinfo()释放
    • ret_info->ai_addrret_info->ai_next是最常用的两个成员,前者是sockaddr *型的,可以给用户使用,后者是链表的next.
      • 输入的hint相当于额外的输入参数,主要用于过滤返回的ret_info

为sockfd分配网络地址,以及监听.

  • 对于大部分数据传输的API, socket库会自动为socketfd自动bind一个可用的地址. 如果自动地址不能满足需求,可以手动bind地址.
  • int bind(int sockfd,sockaddr* local_addr,socklen_t)
    • 这里, local_addr必须是位于本机的一个合法地址
    • 一般一个sockfd只能绑定到一个local_addr上,一个local_addr上可以绑定多个sockfd
  • 对于有连接的协议,服务端需要先listen,再accept,对于客户机,可以connect到阻塞在accept的服务端上
    • listen后,才可以调用accept()
    • accept用于等待外部的connect()请求(默认会阻塞线程),当和远端建立连接后,会返回一个新的sockfd_new, 原来的fd仍将服务于监听工作.

accept是负责实现TCP三次握手的部分; 远端先发信过来,报告远端的地址;本机再向远端发送一个socket_new的地址;远端再回复一个"收到", 连接就算建立完成了

  • 对于已经建立连接的fd,可以用getsocknamegetpeername来查找自身以及peer的addr.
  • 对于无连接的协议, connect也可以正常工作,此时,其逻辑功能是:为fd绑定一个远端地址,这样就可以避免sendto和recivefrom的调用,使用标准的read/write系统调用,简化开发流程.

  • UNIX域套接字有两个典型场景, 这是其他手段不易/不能实现的
    • 进程间传递fd: 在STREAM废除之后,只有UNIX域套接字可以在进程间传递打开了的文件描述符.
    • 进程间数据报:可以绕开网络驱动直接实现进程间可靠地数据报机制.
  • UNIX域套接字的地址需要按文件系统来定义.
    • UNIX域套接字会在bind时自动创建一个文件系统中的文件.
    • UNIX域套接字的地址只能被绑定一次,在绑定前,可以尝试在文件系统中删除对应的sock文件.
      struct sockaddr_un{
      sa_family_t family;
      char addr[MAX_LENGTH];

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注