Linux环境编程1

[TOC]

Linux环境编程

零、基础知识

0.1 一个Linux程序的诞生过程

  1. Linux程序示例代码(0_1_hello_world.c)

    #include 
    int main(void)
    {
        printf("Hello world!\n");
        return 0;
    }
    
  2. 使用gcc生成可执行程序: gcc -g -Wall 0_1_hello_world.c -o hello_world

  3. 整个过程看似简单, 其实涉及预处理、 编译、 汇编和链接等多个步骤

  4. 预处理:

    • 用于处理预处理命令
    • 对于上面的代码来说, 唯一的预处理命令就是#include. 它的作用是将头文件的内容包含到本文件中
    • gcc -E 0_1_hello_world.c 命令在预处理后自动停止后面的操作, 并把预处理的结果输出到标准输出
    • gcc -E 0_1_hello_world.c > 0_1_hello_world.i 命令得到预处理后的文件, 以 .i 结尾
    • 为什么不能在头文件中定义全局变量? 因为定义全局变量的代码会存在于所有以#include包含该头文件的文件中, 也就是说所有的这些文件, 都会定义一个同样的全局变量, 这样就不可避免地造成了冲突
  5. 编译:

    • 指对源代码进行语法分析,并优化产生对应的汇编代码的过程
    • gcc -S 0_1_hello_world.c -o 0_1_hello_world.s 命令,gcc的-S选项会让gcc在编译完成后停止后面的工作, 这样只会产生对应的汇编文件
  6. 汇编:

    • 指将源代码翻译成可执行的指令, 并生成目标文件
    • gcc -c 0_1_hello_world.c -o 0_1_hello_world.o
  7. 链接:

    • 将各个目标文件——包括库文件(库文件也是一种目标文件) 链接成一个可执行程序
    • 在这个过程中, 涉及的概念比较多, 如地址和空间的分配、 符号解析、 重定位等。 在Linux环节下, 该工作是由GNU的链接器ld完成的
  8. 可以使用-v选项来查看完整和详细的gcc编译过程

    • gcc -g -Wall -v 0_1_hello_word.c -o hello_world

0.2 程序的构成

  1. Linux下二进制可执行程序的格式一般为ELF格式。 以上面的hello world为例, 使用readelf查看其ELF格式, 内容如下:


    Screen Shot 2019-10-06 at 3.47.55 PM.png
  2. ELF文件的主要内容就是由各个section及symbol表组成的

  3. 在上面的section列表中, 大家最熟悉的应该是text段、 data段和bss段. text段为代码段, 用于保存可执行指令

  4. text段为代码段, 用于保存可执行指令。 data段为数据段, 用于保存有非0初始值的全局变量和静态变量. bss段用于保存没有初始值或初值为0的全局变量和静态变量, 当程序加载时, bss段中的变量会被初始化为0

0.3 程序如何“跑”的

  1. 在Linux环境下, 可以使用 strace 跟踪系统调用,从而帮助自己研究系统程序加载、 运行和退出的过程


    Screen Shot 2019-10-06 at 4.02.56 PM.png
  2. 首先是由shell调用fork, 然后在子进程中来真正执行这个命令(这一过程在strace输出
    中无法体现) 。 strace是hello_world开始执行后的输出。 首先是调用execve来加载hello_world, 然后ld会分别检查ld.so.nohwcap和ld.so.preload。 其中, 如果
    ld.so.nohwcap存在, 则ld会加载其中未优化版本的库。 如果ld.so.preload存在, 则ld会加载其中的库——在一些项目中, 我们需要拦截或替换系统调用或C库, 此
    时就会利用这个机制, 使用LD_PRELOAD来实现。 之后利用mmap将ld.so.cache映射到内存中, ld.so.cache中保存了库的路径, 这样就完成了所有的准备工作。 接
    着ld加载c库——libc.so.6, 利用mmap及mprotect设置程序的各个内存区域, 到这里, 程序运行的环境已经完成。 后面的write会向文件描述符1(即标准输出) 输
    出"Hello world! \n", 返回值为13, 它表示write成功的字符个数。 最后调用exit_group退出程序, 此时参数为0, 表示程序退出的状态——此例中hello-world程序
    返回0。

0.4 系统调用

  1. 系统调用是操作系统提供的服务, 是应用程序与内核通信的接口

  2. 相对于普通的函数调用来说, 系统调用的性能消耗也是巨大的。 所以在追求极致性能的程序中, 都在尽力避免系统调用, 譬如C库的gettimeofday就避免了系统调用

  3. 用户空间的程序默认是通过栈来传递参数的。 对于系统调用来说, 内核态和用户态使用的是不同的栈,这使得系统调用的参数只能通过寄存器的方式进行传递

  4. 在写代码的时候, 程序员根本不用关心参数是如何传递的, 编译器已经默默地为我们做了一切——压栈、 出栈、 保存返回地址等操作, 但是编译器如何知道调用的函数是普通函数, 还是系统调用呢? 如果是后者, 编译器就不能简单地使用栈来传递参数了。这需要理解C库函数

0.5 C库函数

  1. C库函数为编译器解决了系统调用的问题

  2. Linux环境下, 使用的C库一般都是glibc, 它封装了几乎所有的系统调用, 代码中使用的“系统调用”,实际上就是调用C库中的函数。 C库函数同样位于用户态, 所以编译器可以统一处理所有的函数调用, 而不用区分该函数到底是不是系统调用。

  3. 在Linux平台下,系统调用的约定是使用寄存器eax来传递系统调用号的

0.6 线程安全

  1. 代码可以在多线程环境下“安全”地执行。即符合正确的逻辑结果。实现线程安全两种方式

    • 代码要么只能使用局部变量或资源
    • 或利用锁等同步机制, 来实现全局变量或资源的串行访问。
  2. 经典的多线程不安全代码(0_4_thread.c)

    #include 
    #include 
    #include 
    
    static int counter = 0;
    #define LOOPS 10000000
    
    /* 线程函数,线程任务 */
    static void *thread(void *unused) {
        int i;
        for (i = 0; i < LOOPS; ++i) {
            ++counter;
        }
        return NULL;
    }
    
    int main() {
        pthread_t t1, t2;
        pthread_create(&t1, NULL, thread, NULL);
        pthread_create(&t2, NULL, thread, NULL);
        pthread_join(t1, NULL);
        pthread_join(t2, NULL);
        printf("Counter is %d by threads.\n", counter);
        return 0;
    }
    
    // 执行, 输出 Counter is 11354943 by threads.
    gcc -g 0_4_thread.c -o therad
    ./thread
    
    • 以上代码创建了两个线程, 用来实现对同一个全局变量进行自加运算, 循环次数为一千万次,最后的结果不是期望的20000000,原因?
  3. 对线程函数进行反汇编, 代码如下:

    gcc -c -o therad.c -o therad.o
    dump -s -d thread.o > thread.o.txt
    
    Screen Shot 2019-10-06 at 10.17.12 PM.png
  4. ++counter的汇编代码逻辑:

    • counter的值赋给寄存器EAX
    • 对寄存器EAX的值加1
    • 将EAX的值赋给counter
  5. 当两个线程同时执行++counter时,会有如下情况(每个线程会有独立的上下文执行环境, 所以可视为每个线程都有一个“独立”的EAX)


    Screen Shot 2019-10-06 at 10.34.21 PM.png
  6. 两线程都对counter执行了自增动作,但是最终的结果是“1”而不是“2”。是因为++counter的执行指令并不是原子的,多个线程对counter的并发访问造成了最后的错误结果。利用锁就可以保证counter自增指令的串行化:


    Screen Shot 2019-10-06 at 10.37.31 PM.png

0.7 原子性

操作是原子的, 那么这个操作将是不可分割的, 要么成功, 要么失败, 不会有任何的中间状态

0.8 可重入函数

  1. 可重入就是可重复进入且能成功执行,指当前进程已经处于该函数中,这时程序会允许当前进程的某个执行流程再次进入该函数, 而不会引发问题

  2. 这里的执行流程不仅仅包括多线程, 还包括信号处理、 longjump等执行流程

  3. 所以, 可重入函数一定是线程安全的, 而线程安全函数则不一定是可重入函

  4. 当函数使用锁的时候,尤其是互斥锁的时候,该函数是不可重入的,否则会造成死锁(此时函数虽然线程安全,但是互斥锁在重入时造成死锁,所以不可重入)

  5. 若函数使用了静态变量,并且其工作依赖于这个静态变量时,该函数也是不可重入的,否则会造成该函数工作不正常(此时函数就不是线程安全的函数,所以肯定不是可重入函数,如 0_4_thread.c 中线程函数)。

  6. 死锁例子(0_8_signal_mutex.c)

    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    
    /* 互斥变量,理解成锁 */
    static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
    static const char *const caller[2] = {"mutex_thread", "signal handler"};
    static pthread_t mutex_tid;
    static pthread_t sleep_tid;
    static volatile int signal_handler_exit = 0;
    
    /* 工具函数: */
    static void hold_mutex(int c)
    {
        printf("enter hold_mutex [caller %s]\n", caller[c]);
        /* 线程调用该函数让互斥锁上锁,如果该互斥锁已被另一个线程锁定和
        拥有,则调用该线程将阻塞,直到该互斥锁变为可用为止 */
        pthread_mutex_lock(&mutex);
    
        /* 循环是保证所不会再信号处理函数退出前释放掉 */
        while (!signal_handler_exit && c != 1) {
            sleep(5);
        }
        /* 解锁 */
        pthread_mutex_unlock(&mutex);
        printf("leave hold_mutex [caller %s]\n", caller[c]);
    }
    
    /* 线程任务1 */
    static void  *mutex_thread(void *arg)
    {
        hold_mutex(0);
    }
    
    /* 线程任务2 */
    static void  *sleep_thread(void *arg)
    {
        sleep(10);
    }
    
    static void signal_handler(int signum) 
    {
        hold_mutex(1);
        signal_handler_exit = 1;
    }
    
    int main() {
        signal(SIGUSR1, signal_handler);
        /* 传入线程函数/线程任务,创建线程 */
        pthread_create(&mutex_tid, NULL, mutex_thread, NULL);
        pthread_create(&sleep_tid, NULL, sleep_thread, NULL);
        /* 向 sleep_tid 线程传递一个信号 */
        pthread_kill(sleep_tid, SIGUSR1);
        /* 等待线程执行完毕,当前线程阻塞 */
        pthread_join(mutex_tid, NULL);
        pthread_join(sleep_tid, NULL);
        return 0;
    }
    
    // 执行
    gcc 0_8_signal_mutex.c -o signal_mutex -l pthread
    ./signal_mutex   // 发生死锁
    
  7. 上例函数 hold_mutex 是不可重入的函数(因使用 pthread_mutex 互斥量) 当 mutex_thread 获得 mutex 时, sleep_thread 就收到了信号, 再次调用就进入了 hold_mutex. 结果始终无法拿到mutex, 信号处理函数无法返回, 正常的程序流程也无法继续, 这就造成了死锁.

0.9 阻塞与非阻塞

  1. 阻塞与非阻塞, 都是指I/O操作
  2. Linux环境下, 所有的I/O系统调用默认都是阻塞的
  3. 何谓阻塞? 当进行系统调用时, 除非出错 (被信号打断也视为出错), 进程将会一直陷入内核态直到调用完成
  4. 非阻塞的系统调用是指无论I/O操作成功与否, 调用都会立刻返回

0.10 同步与非同步

  1. 同步与非同步, 也是指I/O操作. 这里的同步和非同步, 是指I/O数据的复制工作是否同步执行

  2. 非同步即异步, 则I/O操作不是随系统调用同步完成的。 调用返回后, I/O操作并没有完成, 而是由操作系统或者某个线程负责真正的I/O操作, 等完成后通知原来的线程

  3. 同步是否就是阻塞, 非同步是否就是非阻塞呢? 实际上在I/O操作中, 它们是不同的概念。 同步既可以是阻塞的, 也可以是非阻塞的, 而常用的Linux的I/O调用实际上都是同步的

一、文件I/O

1.1 Linux 中文件

  1. Linux内核将一切视为文件,广义的文件则可以是Linux管理的所有对象。这些广义的文件利用VFS机制, 以文件系统的形式挂载在Linux内核中, 对外提供一致的文件操作接口

  2. 文件描述符是一个非负整数,其本质就是一个句柄。何为句柄呢?一切对于用户透明的返回值, 即可视为句柄。用户空间利用文件描述符与内核进行交互;而内核拿到文件描述符后,可以通过它得到用于管理文件的真正的数据结构。使用文件描述符即句柄, 有两个好处:

    • 增加了安全性, 句柄类型对用户完全透明, 用户无法通过任何hacking的方式, 更改句柄对应的内部结果
    • 增加了可扩展性,用户的代码只依赖于句柄的值,这样实际结构的类型就可以随时发生变化,与句柄的映射关系也可以随时改变,这些变化都不会影响任何现有的用户代码
  3. Linux的每个进程都会维护一个文件表,以便维护该进程打开文件的信息,包括打开的文件个数、每个打开文件的偏移量等信息。

1.2 打开文件

1.3 create

1.4 关闭文件

你可能感兴趣的:(Linux环境编程1)