Linux 基础概念

709 阅读12分钟

POSIX 与 Libc 库

不同的操作系统会提供不同的系统调用,为保证同一应用可移植到不同的操作系统,形成了 POSIX 接口标准,通常该标准使用 libc 实现

android 中对应的实现是 bionic,相关源码,它里面基本上都是汇编实现。

通常,应用程序只需要调用 Libc 提供的方法就可实现系统调用,同时也实现了在相关操作系统上的移植。

linux 常用命令

  1. ll:查看文件详细信息。mac 中可通过设置别名的方式使用 ll 命令:alias ll="ls -alF",也可使用 ls -l,结果一样
  2. more:与cat类似 但每次读一屏按空格翻页
  3. tail:从后往前查看。tail -200 读取后两百行,tail -f 实时读取
  4. head:从前往后读
  5. grep -A/B/C:-A 搜索结果行的后 10 行;-B 前10行;-C 前后 10 行
  6. df -h:展示整个磁盘使用情况
  7. du -sh:展示某个文或文件夹大小
  8. su 与 sudo:前者表示切换到超级用户,后者以超级用户的身份执行命令,执行完后保留当前用户。
    • 超级用户前面是 #,而普通用户前面是 $

文件权限

  1. r:读权限;w:写权限;x:执行权限;-:没相应权限
  2. 通过 ls -l 可查看文件权限
    • drwxr-xr-x:第一个 d 表示文件类型( d 表示文件夹,- 表示文件),后面 9 个字母依次是权限:当前用户权限,同组用户权限,其他用户权限

vim

  1. /abc: 表示搜索 abc,按 n 跳转去下一个搜索结果。非编辑模式下使用
  2. yy: 复制当前行

驱动

操作硬件的程序

  1. 驱动一般分为字符驱动、块驱动。两者的主要区别在于以什么样的形式与驱动交换数据,前者以字符流,后者以数据块
  2. 应用层通过调用 open/read/write/ioctl 等文件 IO 对硬件进行访问。这些操作都是系统调用,最终会在内核中调用驱动程序中相应的方法
  3. 驱动程序是定义在结构体 file_operations 中的各种函数
  4. 设备文件指定义在 /dev 目录下的文件,应用层通过文件 IO 操作设备文件,设备文件通过设备号关联上对应的设备驱动
    • 设备号由主设备号+次设备号组成。主设备号用于区分不同种类,次设备号用于在同一种中区分不同的个体
    • 对于 binder 来说,它的设备文件是 /dev/binder 文件,信息如下:其中 10 表示主设备号,58 表示次设备号
    crw-rw-rw- 1 root   root    10,  58 2020-12-02 22:52 binder
    
  5. 任何驱动程序写完之后,都需要向内核注册,只有这样 open/ioctl 等系统调用才可以执行到驱动中的方法。注册一般写在初始化函数中,方法名为 xx_init
  6. misc 设备:主设备号为 10,调用 misc_register 可以向系统进行注册
    • binder 就属于 misc 设备
  7. 系统调用涉及到的各个方法可通过命令 man 2|3 <name> 查看相关文档

常用函数

socketpair

创建一对已连接到一起的 socket。这两 socket 类似于管道,一端写入另一端读,但它是两端都可读写。以下是 android 源码中 InputChannel 中使用。源码连接 InputTransport.cpp - Android Code Search

   int sockets[2];
   // 两个 socket 的 fd 会存储到数组中
   socketpair(AF_UNIX, SOCK_SEQPACKET, 0, sockets)

fork

  1. 从当前进程 fork 出一个子进程,子进程的执行位置也是在 fork() 调用之后。它的返回值在不同的进程中结果不一样:父进程中返回子进程 pid,子进程中返回 0。所以经常在 fork() 后看到 if 判断
pid_t pid = fork();
if (pid < 0) {
    // fork 失败
} else {
    if (pid == 0) {
        // 子进程
    } else {
        // 父进程
    }
}
  1. fork() 完成后父子进程拥有相同的内存、寄存器、程序计数器等,但它们是不同的进程,拥有不同的 pid 及虚拟内存空间

clone

功能基本上与 fork 类型,但可细粒度地支持父子进程间的资源共享。例如,fork() 创建的父子进程拥有不同的虚拟内存空间,但 通过 clone() 指定父子进程共享虚拟内存空间

就因为如此 pthread_create 内部使用 clone() 创建线程,只不过在创建时指定共享虚拟内存空间

pthread_atfork

  1. fork() 出的子进程只有一个线程,即调用 fork() 函数的线程,但会继承各种互斥量
  2. 因此,在多线程中,若某个互斥量在父进程中被加锁,那么到子进程中一定也是被加锁状态,虽然子进程并没有执行加锁操作,而且互斥量只有加锁的线程才能解锁。就这有可能导致死锁
  3. pthread_atfork 可解决上述问题。第一个参数在调用 fork 前调用,在这里可以对需要的互斥量加锁(如果能成功,子进程就可以解锁;如果加不成功,就会阻塞,不会创建子进程)。后两个参数表示在调用父/子进程代码前的回调,用于解锁第一个参数中加的锁

exec

用于指定当前进程要执行的操作。它会将参数指定的可执行文件载入到内存,然后初始化堆栈,并执行可执行文件的 main() 函数。使用 exec 系统函数,可以在 fork() 后让子进程执行不同的操作。

exit

退出当前进程

ioctl

与驱动交互的方法,可以给驱动传参。方法声明:int ioctl(int fd,unsigned long cmd,...);

  1. cmd 是一个整数,它会分为几部分,不同部分有不同的含义

  2. 第三个参数为一个地址,驱动使用 copy_from_user 从该地址复制数据。如

    // 用户调用
    flat_binder_object obj {
        .flags = FLAT_BINDER_FLAG_TXN_SECURITY_CTX,
    };
    int result = ioctl(mDriverFD, BINDER_SET_CONTEXT_MGR_EXT, &obj);
    
    // 驱动使用
    struct flat_binder_object fbo;
    
    if (copy_from_user(&fbo, ubuf, sizeof(fbo))) {}
    

mmap

作用有二:

  1. 实现共享内存,将同一块物理内存映射到不同的进程空间中。

  2. 将普通文件映射到内存中,然后读写内存一样对文件进行操作。mmap 并不分配空间, 只是将文件映射到调用进程的地址空间里。

    // 操作过程中会有失败的可能,代码中没有进行判断
    int fd = open("/Users/xx/Desktop/test", O_RDWR);
    if(fd > 0){
        char * mm = (char *)mmap(NULL, 8, PROT_READ | PROT_WRITE,
    MAP_SHARED, fd, 0);
        cout << mm << endl;
        // 写入数据
        cout << strcpy(mm, "foma");
        munmap(mm, 8);
        close(fd);
    }
    
  3. 在建立映射时会传入一个标识,若标志为私有,那么某进程对映射内容的修改对其他进程不可见,同时对文件的修改也不会落实到文件上;若为共享,则对其他进程可见,也会落实到文件上

copy_from_user

返回 0 表示成功

我们知道 binder 底层是使用了 copy_from_user 将数据从用户空间复制到内核空间的。所以先了解一下这个方法

unsigned long copy_from_user(void * to, const void __user * from, unsigned long n)
  • 第一个参数 to 是内核空间的数据目标地址指针
  • 第二个参数 from 是用户空间的数据源地址指针
  • 第三个参数 n 是要 copy 的数据长度

如果数据拷贝成功,则返回零;否则,返回没有拷贝成功的数据字节数

copy_to_user

返回 0 表示成功

binder 底层使用了 copy_to_user 将处理后的数据返回给调用者

unsigned long copy_to_user(void __user *to, const void *from, unsigned long n);
  • to是用户空间的指针,
  • from是内核空间指针,
  • n 表示从内核空间向用户空间拷贝数据的字节数

如果数据拷贝成功,则返回零;否则,返回没有拷贝成功的数据字节数。也就是说返回 0 表示成功

epoll

android handler 机制底层使用的就是 epoll,它是 i/o 多路复用的一种技术。所谓多路复用,指的是一个线程可同时监视多个文件句柄,一旦某个文件句柄就绪,应用程序就可以得到通知进面执行读写操作,没有文件句柄就绪时就会阻塞应用程序,交出 CPU。跟 epoll 相关的系统调用函数一共有三个:epoll_create/epoll_create1,epoll_ctl 和 epoll_wait

epoll_create:创建一个 epoll 对应的文件描述符 epfd,后续的所有操作都基于该参数的返回值

epoll_ctl:向 epfd 中添加程序想要监听的文件描述符

epoll_wait:等待监听的文件描述符发生变化,没有时会阻塞应用程序。最后一个参数为 timeout,分三种情况:

  • -1:表示一直等待,直到有文件描述符进入就绪状态
  • 0:检测是否有文件描述符处于就绪状态,因为不会阻塞当前应用程序
  • 大于0:指定阻塞时间。如果期间有文件描述符就绪,会立即返回

它的返回值也分为三种情况:

  • < 0:表示发生错误
  • = 0:表示等待超时
  • > 0:表示被唤醒

epoll_wait 第二个参数为 epoll_event 数组,内核会就就绪的 fd 对应的 epoll_event 填充到这个数组中。因此,在 epoll_ctl 中传递的 epoll_event 会在就绪时被复制到这个数组中。

以 android 中 Looper 为例,看一下对三个方法的调用

// Looper::rebuildEpollLocked() 中
mEpollFd.reset(epoll_create1(EPOLL_CLOEXEC));

// Looper::addFd() 中
epoll_ctl(mEpollFd.get(), EPOLL_CTL_ADD, fd, &eventItem);

// Looper::pollInner() 中
epoll_wait(mEpollFd.get(), eventItems, EPOLL_MAX_EVENTS, timeoutMillis);

下面看三个方法的参数使用

// 创建
int epfd = epoll_create1(EPOLL_CLOEXEC);

// 添加对 binder_id 的监听
struct epoll_event event;
event.events = EPOLLIN|EPOLLET;// 监听可读可写
event.data.fd = binder_id; // 这个值一定要指定
epoll_ctl(epfd, EPOLL_CTL_ADD, binder_id, &event)

// 死循环,因为不只是监听一次
while(1)
{
    
    // 返回本次有多少文件描述符就绪
    // epoll_ctl 中的 event 会被复制到该数组中
    struct epoll_event eventItems[EPOLL_MAX_EVENTS];
    count = epoll_wait(epollfd,eventItems, EPOLL_MAX_EVENTS, -1);

    for(i = 0; i < count; i++)
    {
        // 如果是 binder_id 发生变化
        if(events[i].data.fd == binder_id)
        {
            // 这里一般会读出变化内容,有可能是另一个 fd,也需要使用 epoll_ctl 添加到监听中
        }
        else if(events[i].events & EPOLLIN) // 普通的 fd,可进行操作
        {
           
        }
    }
}

current

当前进行系统调用的进程

  1. 它其实是一个方法的别名:#define current get_current()
  2. linux 中每一个进程都由 task_struct 数据结构来定义。使用 fork() 时, 内核会重新生成一个 task_struct 对象,然后从父进程中继承一些数据,并加到进程树中,以便进行管理

eventfd

  1. 返回一个 fd。该 fd 指向的是由内核维护的无符号 64 位计数器
  2. 在用户进程,常用来实现 wait/notify 机制,或者内核唤醒用户进程
  3. 通过 write() 向计数器累加值,并不是重置计数器。如果累加结果超出最大上限,那么 write 就会阻塞
  4. 通过 read() 读取计数器的值,读取后会将计数器的值设置为 0。如果计数器的值是 0,那么 read() 会被阻塞。
  5. 通过 3,4 两点就可以实现不同线程/进程之间的唤醒。一个进程/线程 read 时被阻塞,另一个进程/线程 write 后就会唤醒。官方示例

Xnip2022-03-03_16-19-31.png

linux 常用函数

哈希表

哈希表就是 java 中常用的散列表。但凡有 hlist 的就表示哈希表

链接

linux 中将散列表涉及到的类分为两个结构体:表头(存在数组中的)使用 hlist_head,链表中的节点用 hlist_node。对哈希表的各种操作见参考链接

malloc 系列

malloc

用于在用户空间分配内存,不能用于内核

malloc() 在分配时会 额外分配一些字节存储该内存块的大小,而实现返回的是这些字节以后的位置。

calloc

用于给一组相同的对象分配内存

第一个参数为分配对象的数量,第二个参数为每个对象的大小

realloc

调整一块内存的大小。这块内存通常是之前由 malloc 分配的

第一个参数指向要调整的内存块,第二个参数为期望的新内存大小

成功后返回新内存块地址,与原值可能不同。失败时,返回 NULL,且原内存中数据不动。

free

释放某块内存

由于 malloc() 在分配时已经记录了内存块的大小,所以 free() 可以准确地释放掉某块内存。

free() 释放掉的内存块会进入一个缓存列表

malloc() 分配时首先从该表中查找,以期找到一个足够大(比需要大或与需求相等)的内存块。如果大小刚好相等,直接返回。如果比需求大,则将大的分割成两块,将大小相等的一块返回给调用者,剩余的继续留在列表中。

如果列表中没有,则调用 sbrk() 移动 program break(可以理解为堆顶)扩大堆内存。当然,为减少对 sbrk() 的调用,并不是严格按照 malloc() 的需求进行扩容,而是以更大的容量进行,用不上的部分也会落入缓存列表。

其他

  1. kmalloc:在内核中分配连续的内存,它要求对应的物理内存也必须连续,且不能超过 128K
  2. kzalloc:基于 kmalloc,但将内存区域强制清零
  3. vmalloc:在内核中分配一块连续的内存,但物理内存中不一定连续,不限制大小
  4. kcalloc:在内核中申请一个数组的内存空间,同时将内存清零,第一个参数为数组元素个数,第二个为每一个元素的大小
  5. alloc_page:分配一页物理内存,返回的是物理内存的地址
  6. alloc_pages(mask, order):分配 2^order 页物理内存,返回的也是物理内存的起始地址

常用宏函数

container_of

通过成员变量的地址得到它所在结构体的首地址

第一个参数表示成员变量的地址,第二个参数表示它所属的结构体类型,第三个参数为成员变量在结构体中的名字。

以一个 binder 驱动中使用的为例

#define to_flat_binder_object(hdr) \
    container_of(hdr, struct flat_binder_object, hdr)

第二个参数为结构体 flat_binder_object,它内部有一个属性是 hdr(即第三个参数);第一个参数是一个 hdr 的首地址,看着和第三个参数一样,但实际上完全不同。

进程休眠

binder 驱动中使用 schedule 进行休眠。参考这篇文件 关于 Linux 进程的睡眠和唤醒。下面是 binder 驱动中关于休眠的代码:


static int binder_wait_for_work(struct binder_thread *thread,
				bool do_proc_work)
{
	for (;;) {
		prepare_to_wait(&thread->wait, &wait, TASK_INTERRUPTIBLE);
		binder_inner_proc_unlock(proc);
		schedule();
	}
}

内核中 pid 与 tgid

在内核中,线程就是进程。翻译成普通理解的进程、线程:pid 指线程 id,tgid 指进程 id可参考里面的图

  1. pid:内核中就是线程的 id,从内核角度看也是进程 id
  2. tgid:记录它所属的进程。此时要当普通意义上的线程看

信号

我们知道 Linux 中每一个进程都可以注册自己的信号处理函数。基础知识如下:

  1. 跟注册信号处理函数相关的是 struct sigaction,用于指定信号处理函数等信息。下面是 xcrash 中的代码
    // 创建 sigaction 对象
    struct sigaction act;
    // 将 act 都设置为 0
    memset(&act, 0, sizeof(act));
    // sigfillset 作用是把信号集初始化包含所有已定义的信号,即屏蔽所有信号
    // 当调用处理函数过程中临时使用 sa_mask 代替进程的阻塞信号集
    // sigfillset 是信号集函数,表示将向 sa_mask 中添加所有的信号,也就是在调用 handler 过程中
    // 进程会屏蔽所有信号
    sigfillset(&act.sa_mask);
    act.sa_sigaction = handler; // 指定信号处理函数
    
  2. 使用 sigaction() 指定自己的信号处理函数。第一个参数指定处理哪个信号,第二个为处理函数,第三个参数用于接收旧的处理函数。xcrash 中代码
    // xcc_signal_crash_info 为 scrash 定义的一个结构体,signum 指定在处理哪些信号
    // oldact 用于接收旧的处理函数
    sigaction(xcc_signal_crash_info[i].signum, &act, &(xcc_signal_crash_info[i].oldact))
    
  3. 一个进程可以多次调用 sigaction() 注册处理函数,但后注册的会覆盖掉先注册的,也就是说进程中的线程收到信号时会按最后一次指定的处理函数处理。
  4. 一个进程可以有多个线程,具体由哪个线程调用处理函数,得分情况,链接:
    • 如果是异常产生的信号(比如程序错误,像SIGPIPE、SIGEGV这些),则只有产生异常的线程收到并处理
    • 使用 pthread_kill 向指定线程发送信号,则由指定线程处理
    • 使用 kill 向指定进程发送信号,则 会遍历所有线程,直到找到一个不阻塞该信号的线程

信号集

链接

  1. 一个进程或线程可以屏蔽(阻塞)某些信号,也有一些信号无法屏蔽,官方话叫 mask
  2. 通过 sigprocmask 设置进程信号集(可以添加屏蔽某些信号,也可以解除屏蔽),通过 pthread_sigmask 设置线程信号集(可添加屏蔽,也可解除屏蔽)。两个方法参数类似,第一个参数表示怎么操作信号集(是添加新的信号,还是解除屏蔽,还是重置信号集),第二个表示新信号集,第三个用于接收老的信号集。第一个参数取值如下:
    • SIG_UNBLOCK:解除对第二个参数中信号的屏蔽
    • SIG_BLOCK:添加对第二个参数中信号的屏蔽
    • SIG_SETMASK:将信号集重置为第二个参数
  3. 信号集定义为 sigset_t 类型。xcrash 中的代码
    sigset_t         set;
    // 信号集。sigemptyset 初始化一个空的信号集,sigaddset 向信号集中添加 SIGQUIT
    sigemptyset(&set);
    sigaddset(&set, SIGQUIT);
    // 设置当前线程不屏蔽 SIGQUIT 信号,解除对 sigquit 信号的屏蔽
    if(0 != (r = pthread_sigmask(SIG_UNBLOCK, &set, &xcc_signal_trace_oldset))) return r;