[TOC]
Linux环境编程
零、基础知识
0.1 一个Linux程序的诞生过程
-
Linux程序示例代码(0_1_hello_world.c)
#include <stdio.h> int main(void) { printf("Hello world!\n"); return 0; } -
使用gcc生成可执行程序: gcc -g -Wall 0_1_hello_world.c -o hello_world
-
整个过程看似简单, 其实涉及预处理、 编译、 汇编和链接等多个步骤
-
预处理:
- 用于处理预处理命令
- 对于上面的代码来说, 唯一的预处理命令就是#include. 它的作用是将头文件的内容包含到本文件中
- gcc -E 0_1_hello_world.c 命令在预处理后自动停止后面的操作, 并把预处理的结果输出到标准输出
- gcc -E 0_1_hello_world.c > 0_1_hello_world.i 命令得到预处理后的文件, 以 .i 结尾
- 为什么不能在头文件中定义全局变量? 因为定义全局变量的代码会存在于所有以#include包含该头文件的文件中, 也就是说所有的这些文件, 都会定义一个同样的全局变量, 这样就不可避免地造成了冲突
-
编译:
- 指对源代码进行语法分析,并优化产生对应的汇编代码的过程
- gcc -S 0_1_hello_world.c -o 0_1_hello_world.s 命令,gcc的-S选项会让gcc在编译完成后停止后面的工作, 这样只会产生对应的汇编文件
-
汇编:
- 指将源代码翻译成可执行的指令, 并生成目标文件
- gcc -c 0_1_hello_world.c -o 0_1_hello_world.o
-
链接:
- 将各个目标文件——包括库文件(库文件也是一种目标文件) 链接成一个可执行程序
- 在这个过程中, 涉及的概念比较多, 如地址和空间的分配、 符号解析、 重定位等。 在Linux环节下, 该工作是由GNU的链接器ld完成的
-
可以使用-v选项来查看完整和详细的gcc编译过程
- gcc -g -Wall -v 0_1_hello_word.c -o hello_world
0.2 程序的构成
-
Linux下二进制可执行程序的格式一般为ELF格式。 以上面的hello world为例, 使用readelf查看其ELF格式, 内容如下:
Screen Shot 2019-10-06 at 3.47.55 PM.png
-
ELF文件的主要内容就是由各个section及symbol表组成的
-
在上面的section列表中, 大家最熟悉的应该是text段、 data段和bss段. text段为代码段, 用于保存可执行指令
-
text段为代码段, 用于保存可执行指令。 data段为数据段, 用于保存有非0初始值的全局变量和静态变量. bss段用于保存没有初始值或初值为0的全局变量和静态变量, 当程序加载时, bss段中的变量会被初始化为0
0.3 程序如何“跑”的
-
在Linux环境下, 可以使用 strace 跟踪系统调用,从而帮助自己研究系统程序加载、 运行和退出的过程
Screen Shot 2019-10-06 at 4.02.56 PM.png
-
首先是由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 系统调用
-
系统调用是操作系统提供的服务, 是应用程序与内核通信的接口
-
相对于普通的函数调用来说, 系统调用的性能消耗也是巨大的。 所以在追求极致性能的程序中, 都在尽力避免系统调用, 譬如C库的gettimeofday就避免了系统调用
-
用户空间的程序默认是通过栈来传递参数的。 对于系统调用来说, 内核态和用户态使用的是不同的栈,这使得系统调用的参数只能通过寄存器的方式进行传递
-
在写代码的时候, 程序员根本不用关心参数是如何传递的, 编译器已经默默地为我们做了一切——压栈、 出栈、 保存返回地址等操作, 但是编译器如何知道调用的函数是普通函数, 还是系统调用呢? 如果是后者, 编译器就不能简单地使用栈来传递参数了。这需要理解C库函数
0.5 C库函数
-
C库函数为编译器解决了系统调用的问题
-
Linux环境下, 使用的C库一般都是glibc, 它封装了几乎所有的系统调用, 代码中使用的“系统调用”,实际上就是调用C库中的函数。 C库函数同样位于用户态, 所以编译器可以统一处理所有的函数调用, 而不用区分该函数到底是不是系统调用。
-
在Linux平台下,系统调用的约定是使用寄存器eax来传递系统调用号的
0.6 线程安全
-
代码可以在多线程环境下“安全”地执行。即符合正确的逻辑结果。实现线程安全两种方式
- 代码要么只能使用局部变量或资源
- 或利用锁等同步机制, 来实现全局变量或资源的串行访问。
-
经典的多线程不安全代码(0_4_thread.c)
#include <pthread.h> #include <stdio.h> #include <stdlib.h> 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,原因?
-
对线程函数进行反汇编, 代码如下:
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
-
++counter的汇编代码逻辑:
- counter的值赋给寄存器EAX
- 对寄存器EAX的值加1
- 将EAX的值赋给counter
-
当两个线程同时执行++counter时,会有如下情况(每个线程会有独立的上下文执行环境, 所以可视为每个线程都有一个“独立”的EAX)
Screen Shot 2019-10-06 at 10.34.21 PM.png
-
两线程都对counter执行了自增动作,但是最终的结果是“1”而不是“2”。是因为++counter的执行指令并不是原子的,多个线程对counter的并发访问造成了最后的错误结果。利用锁就可以保证counter自增指令的串行化:
Screen Shot 2019-10-06 at 10.37.31 PM.png
0.7 原子性
操作是原子的, 那么这个操作将是不可分割的, 要么成功, 要么失败, 不会有任何的中间状态
0.8 可重入函数
-
可重入就是可重复进入且能成功执行,指当前进程已经处于该函数中,这时程序会允许当前进程的某个执行流程再次进入该函数, 而不会引发问题
-
这里的执行流程不仅仅包括多线程, 还包括信号处理、 longjump等执行流程
-
所以, 可重入函数一定是线程安全的, 而线程安全函数则不一定是可重入函
数 -
当函数使用锁的时候,尤其是互斥锁的时候,该函数是不可重入的,否则会造成死锁(此时函数虽然线程安全,但是互斥锁在重入时造成死锁,所以不可重入)
-
若函数使用了静态变量,并且其工作依赖于这个静态变量时,该函数也是不可重入的,否则会造成该函数工作不正常(此时函数就不是线程安全的函数,所以肯定不是可重入函数,如 0_4_thread.c 中线程函数)。
-
死锁例子(0_8_signal_mutex.c)
#include <stdio.h> #include <stdlib.h> #include <pthread.h> #include <unistd.h> #include <signal.h> #include <sys/types.h> /* 互斥变量,理解成锁 */ 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 // 发生死锁 -
上例函数 hold_mutex 是不可重入的函数(因使用 pthread_mutex 互斥量) 当 mutex_thread 获得 mutex 时, sleep_thread 就收到了信号, 再次调用就进入了 hold_mutex. 结果始终无法拿到mutex, 信号处理函数无法返回, 正常的程序流程也无法继续, 这就造成了死锁.
0.9 阻塞与非阻塞
- 阻塞与非阻塞, 都是指I/O操作
- Linux环境下, 所有的I/O系统调用默认都是阻塞的
- 何谓阻塞? 当进行系统调用时, 除非出错 (被信号打断也视为出错), 进程将会一直陷入内核态直到调用完成
- 非阻塞的系统调用是指无论I/O操作成功与否, 调用都会立刻返回
0.10 同步与非同步
-
同步与非同步, 也是指I/O操作. 这里的同步和非同步, 是指I/O数据的复制工作是否同步执行
-
非同步即异步, 则I/O操作不是随系统调用同步完成的。 调用返回后, I/O操作并没有完成, 而是由操作系统或者某个线程负责真正的I/O操作, 等完成后通知原来的线程
-
同步是否就是阻塞, 非同步是否就是非阻塞呢? 实际上在I/O操作中, 它们是不同的概念。 同步既可以是阻塞的, 也可以是非阻塞的, 而常用的Linux的I/O调用实际上都是同步的
一、文件I/O
1.1 Linux 中文件
-
Linux内核将一切视为文件,广义的文件则可以是Linux管理的所有对象。这些广义的文件利用VFS机制, 以文件系统的形式挂载在Linux内核中, 对外提供一致的文件操作接口
-
文件描述符是一个非负整数,其本质就是一个句柄。何为句柄呢?一切对于用户透明的返回值, 即可视为句柄。用户空间利用文件描述符与内核进行交互;而内核拿到文件描述符后,可以通过它得到用于管理文件的真正的数据结构。使用文件描述符即句柄, 有两个好处:
- 增加了安全性, 句柄类型对用户完全透明, 用户无法通过任何hacking的方式, 更改句柄对应的内部结果
- 增加了可扩展性,用户的代码只依赖于句柄的值,这样实际结构的类型就可以随时发生变化,与句柄的映射关系也可以随时改变,这些变化都不会影响任何现有的用户代码
-
Linux的每个进程都会维护一个文件表,以便维护该进程打开文件的信息,包括打开的文件个数、每个打开文件的偏移量等信息。








网友评论