读《程序员的自我修养》

2018-03-17
4 min read

参考:《程序员的自我修养》

下图源自一篇日文博客(不用知道文字的意义,我反正是看不懂 🙃,这张图只是用来表示代码和可执行文件之间的关系),感谢原作者~

Tips

  1. MMU(memory manager unit),虚拟内存的硬件基础
  2. 编译器的乱序 && CPU 的乱序
    1. 编译器的乱序可以使用 volatile/mutex/atomic 等手段禁止
    2. CPU 的乱序只能使用内存栅栏禁止
  3. volatile 有两项功能
    1. 强制编译器写穿透
    2. 阻止编译器调整对应变量的指令顺序(无法阻止 CPU 的乱序执行)
  4. 预处理–>编译(汇编代码/cc1)–>汇编(二进制对象/as)–>链接(可执行文件/ld

过度优化

Double Check 的陷阱

Double Check 缺陷的主要原因是 CPU 的乱序执行(推荐使用 std::call_once

volatile T* instance_ptr = nullptr;
T* get_instance() {
    if(instance_ptr == nullptr) {
        lock();
        if(instance_ptr == nullptr) instance_ptr = new T;
        unlock();
    }
    return instance_ptr;
}

第二次 check 与对象创建有风险,主要是因为 new 与赋值有三个过程,其中第二步和第三步有乱序的可能:

  1. 调用 malloc 等函数分配内存
  2. 调用 T 的构造函数初始化内存
  3. 返回内存地址赋值 instance_ptr

如果第二步和第三步乱序,那么其他线程可能看到了一个未初始化完全的对象,程序的行为是未知的

二进制文件

ELF 的大致结构如下图所示:

下图是 COFF 文件变种的内容,包含了 share/static 文件的详细内容:

.data && .bss

初始化的全局变量和局部静态变量会被分配到 .data 段, 未初始化的全局变量和局部静态变量会被分配到 .bss 段

.bss 段并不占用可执行文件的存储空间(具体依赖平台的实现),因为未初始化的全局变量和局部静态变量在程序载入内存的时候会被初始化为 0 ,这个过程可以直接在分配内存的时候完成(此时静态变量的类型是知道的)

.data 的存在主要是为了保存初始化变量的值,这些值不保存在数据段就要保存在代码段,保存在代码段无法修改

因为编译器的优化,初始化为 0 的全局静态变量可能会被保存在 .bss 中

.rodata && .data

只读数据段用于保存运行时常量,比如字符串、const 修饰的变量等等

不同平台下只读数据段的处理方式不同。嵌入式场景下只读数据段可能放在 ROM 中,代码中尝试修改此类变量有可能造成系统崩溃;x86 等场景下只读段可能与 .data 段合并,const 此时只是编译时的约束,通过一定的技巧还是可以修改此类变量的(比如指针);对于比较严格的系统,只读变量所在的虚存块会被设置为只读,此时尝试修改变量会造成进程崩溃

.rel.text 重定位表

编译单元中对外部函数的引用信息常保存在重定位表中

符号表

当前对象中包含的符号

强弱符号(略)

静态链接

链接的两个过程

  1. 空间地址分配

    扫描所有目标文件,确定各个段的长度、属性和位置,为链接后的目标文件提供虚内存分配信息

  2. 符号解析与重定位

a.c/b.c

// a.c
extern int shared;
int main() {
     int a = 100;
     swap(&a, &shared);
 }

// b.c 
int shared = 1;
void swap(int *a, int *b) {
     int tmp = *a;
     *a = *b;
     *b = tmp;
 }

objdump -? a.o

-h:注意 VMA 的起始地址

Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         00000051  0000000000000000  0000000000000000  00000040  2**0
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
  1 .data         00000000  0000000000000000  0000000000000000  00000091  2**0
                  CONTENTS, ALLOC, LOAD, DATA
  2 .bss          00000000  0000000000000000  0000000000000000  00000091  2**0
                  ALLOC
......

-d:注意外部符号的地址,这里 swap 的地址被置为 0,因为 a.c 编译时没有 swap 函数的任何信息

0000000000000000 <main>:
......
  31:	e8 00 00 00 00       	callq  36 <main+0x36> # e8 和 b8 是指令码
  36:	b8 00 00 00 00       	mov    $0x0,%eax
......

-r,.rel.text :重定位表明确指出了 a.o .text 段第 32 字节处需要重定位,符号名是 swap,类型是 R_X86_64_PLT32 ,表明是一个函数

RELOCATION RECORDS FOR [.text]:
OFFSET           TYPE              VALUE 
0000000000000025 R_X86_64_PC32     shared-0x0000000000000004
0000000000000032 R_X86_64_PLT32    swap-0x0000000000000004
000000000000004b R_X86_64_PLT32    __stack_chk_fail-0x0000000000000004
......

objdump -? a.out

-h :可执行文件的 VMA 起始地址和 .o 文件不同,此时已经是确定的虚内存地址位置

Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .interp       0000001c  0000000000000238  0000000000000238  00000238  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  1 .note.ABI-tag 00000020  0000000000000254  0000000000000254  00000254  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
......

-r:

a.out:     file format elf64-x86-64

readelf

readelf -s a.o

注意这里 swap 和 shared 两个符号都是 UND,链接时这些符号如果不存在全局符号表中链接器会报错

   Num:    Value          Size Type    Bind   Vis      Ndx Name
......
     8: 0000000000000000    81 FUNC    GLOBAL DEFAULT    1 main
     9: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND shared
    10: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND _GLOBAL_OFFSET_TABLE_
    11: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND swap
    12: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND __stack_chk_fail
......

readelf -s b.o

......
     7: 0000000000000000     0 SECTION LOCAL  DEFAULT    4 
     8: 0000000000000000     4 OBJECT  GLOBAL DEFAULT    2 shared
     9: 0000000000000000    45 FUNC    GLOBAL DEFAULT    1 swap

装载

虚拟内存布局

第七章(待续)

动态链接

Windows 下的动态链接

windows 下的动态链接基于 DLL,windows 下软件和系统的更新很多时候就是对 DLL 的更新

dllexport && dllimport

与 Linux 下动态连接库默认导出所有全局符号不同,DLL 默认不导出任何符号

如果想让外部可见 DLL 中的符号,编译时需告知编译器,示例代码如下:

#if defined(LEVELDB_COMPILE_LIBRARY)
#define LEVELDB_EXPORT __declspec(dllexport)
#else
#define LEVELDB_EXPORT __declspec(dllimport)
#endif  // defined(LEVELDB_COMPILE_LIBRARY)

LEVELDB_EXPORT Status DestroyDB(const std::string& name, const Options& options);

__declspec(dllexport) 表示当前符合可以被外部发现

__declspec(dllimport) 表示当前符号由外部 DLL 导入

/LD && /LDd

使用 MSVC 的 cl 编译命令编译代码并提供 /LD 或者 /LDd 参数可以生成 DLL

编译 DLL 文件的时候会生成4个文件:dll、obj、exp 和 lib,其中 lib 文件是其他编译单元使用 DLL 时的编译依赖(import libary),obj 是生成 dll 文件时生成的中间文件