0%

CSAPP知识笔记(1)

编译、重定位、装入和链接

  • picture 0
    • 编译是得到单个的程序模块
    • 链接是将程序中用到的模块链接在一起,组合他们的虚拟地址(逻辑地址)
    • 装入是得到真正的物理地址

      可重定位文件的结构

      Elf Header部分

  • 存放文件的基本信息

    .text段

  • 存放编译好的机器代码

    .data段

  • 存放已经初始化的全局变量和静态变量

    .bss段

  • 未初始化的全局变量和静态变量以及初始化为0的全局变量和静态变量
  • better save space
  • 并不占据实际的空间,只是一个占位符
  • 系统真正运行的时候会分配空间并且初始化为0

    COMMON

  • 存放未初始化的全局变量

    .rodata

  • 存放只读数据
  • 比如字符串和switch的跳转表

    .rel.text

  • 存放需要重定位的代码

    .rel.data

  • 存放已初始化的数据的重定位

    可执行文件的结构

    ELF Header

  • 文件的总体格式

    .init

  • 初始化用的代码

    .text .rodata和.data

  • 与可重定位文件类似

强符号和弱符号

  • 强符号
    • 函数和已经初始化的全局变量
  • 弱符号
    • 未初始化的全局变量
  • 多个同名的强符号一起出现的时候会引起链接器错误
  • 强符号和弱符号一起出现的时候会同意认为是强符号,而不会报错
    • 类型不同的强弱符号,在弱符号出现的模块中,类型会保留弱符号的类型,尽管实际上内存中存储的是强符号的类型
    • 给编译器-fno-common选项,会在多重定义时报错防止上述错误
  • 多个弱符号也会认为是同一个变量

    运行时的内存映像

  • picture 1
  • 代码段,数据段和堆是相邻的
  • 栈在最大可用地址的位置,从大到小,堆从小到大
  • 程序运行加载的过程
    • picture 2
  • 只编译不链接就得到可重定位目标文件

    一个指令序列加载的例子

    // Generated by GPT-4; unmodified

    #include <stdio.h>
    #include <stdlib.h>
    #include <sys/mman.h>
    #include <fcntl.h>
    #include <unistd.h>

    int main(int argc, char *argv[]) {
    if (argc != 2) {
    printf("Usage: %s <binary_file>\n", argv[0]);
    return 1;
    }

    // Open the binary file
    int fd = open(argv[1], O_RDONLY);
    if (fd < 0) {
    perror("open");
    return 1;
    }

    // Get the file size
    off_t file_size = lseek(fd, 0, SEEK_END);
    lseek(fd, 0, SEEK_SET);

    // Allocate memory for the binary
    void *mem = mmap(NULL, file_size, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE, fd, 0);
    if (mem == MAP_FAILED) {
    perror("mmap");
    close(fd);
    return 1;
    }

    // Close the file
    close(fd);

    // Cast the memory address to a function pointer and call it
    void (*binary_func)() = (void (*)())mem;
    binary_func();

    // Clean up
    munmap(mem, file_size);

    return 0;
    }
  • 也就是打开文件,创建文件和内存位置之间大小等于文件大小的映射关系(mmap),将指定大小的文件映射到内存空间中,然后将映射到的内存地址强制类型转换为函数指针,然后从这个位置开始执行

    一个应用程序加载的例子

  • 应用程序在内存中除了代码段.text,数据段.data和BSS段.bss以外,还包含动态分配内存用的堆和栈空间
    #include <stdint.h>
    #include <stdio.h>
    #include <string.h>
    #include <stdlib.h>
    #include <unistd.h>
    #include <assert.h>
    #include <elf.h>
    #include <fcntl.h>
    #include <sys/mman.h>

    #define STK_SZ (1 << 20)
    #define ROUND(x, align) (((uintptr_t)x) & ~(align - 1))
    #define MOD(x, align) (((uintptr_t)x) & (align - 1))
    #define push(sp, T, ...) ({ *((T*)sp) = (T)__VA_ARGS__; \
    sp = (void *)((uintptr_t)(sp) + sizeof(T)); })

    void execve_(const char *file, char *argv[], char *envp[]) {
    // WARNING: This execve_ does not free process resources.
    // **NOT** all process states are properly initialized.

    int fd = open(file, O_RDONLY);
    assert(fd > 0);

    // Map ELF header to memory
    Elf64_Ehdr *h = mmap(NULL, 4096, PROT_READ, MAP_PRIVATE, fd, 0);
    assert(h != MAP_FAILED);
    assert(h->e_type == ET_EXEC && h->e_machine == EM_X86_64);

    Elf64_Phdr *pht = (Elf64_Phdr *)((char *)h + h->e_phoff);
    for (int i = 0; i < h->e_phnum; i++) {
    Elf64_Phdr *p = &pht[i];
    if (p->p_type == PT_LOAD) {
    // Memory map region
    uintptr_t map_beg = ROUND(p->p_vaddr, p->p_align);
    uintptr_t map_end = map_beg + p->p_memsz;
    while (map_end % p->p_align != 0) map_end++;

    // Memory map flags
    int prot = 0;
    if (p->p_flags & PF_R) prot |= PROT_READ;
    if (p->p_flags & PF_W) prot |= PROT_WRITE;
    if (p->p_flags & PF_X) prot |= PROT_EXEC;

    // Memory map size
    int map_sz = p->p_filesz + (p->p_vaddr % p->p_align);
    while (map_sz % p->p_align != 0) map_sz++;

    // Map file contents to memory
    void *ret = mmap(
    (void *)map_beg, // addr, rounded to ALIGN
    map_sz, // length
    prot, // protection
    MAP_PRIVATE | MAP_FIXED, // flags, private & strict
    fd, // file descriptor
    ROUND(p->p_offset, p->p_align) // offset
    );
    assert(ret != MAP_FAILED);

    // Map extra anonymous memory (e.g., bss)
    intptr_t extra_sz = p->p_memsz - p->p_filesz;
    if (extra_sz > 0) {
    uintptr_t extra_beg = map_beg + map_sz;
    ret = mmap(
    (void *)extra_beg, extra_sz, prot, // addr, length, protection
    MAP_ANONYMOUS | MAP_PRIVATE | MAP_FIXED, // flags
    -1, 0 // no file
    );
    assert(ret != MAP_FAILED);
    }
    }
    }
    close(fd);

    static char stack[STK_SZ], rnd[16];
    void *sp = (void *)ROUND(stack + sizeof(stack) - 4096, 16);
    void *sp_exec = sp;
    int argc = 0;

    // argc
    while (argv[argc]) argc++;
    push(sp, intptr_t, argc);
    // argv[], NULL-terminate
    for (int i = 0; i <= argc; i++)
    push(sp, intptr_t, argv[i]);
    // envp[], NULL-terminate
    for (; *envp; envp++) {
    if (!strchr(*envp, '_')) // remove some verbose ones
    push(sp, intptr_t, *envp);
    }
    // auxv[], AT_NULL-terminate
    push(sp, intptr_t, 0);
    push(sp, Elf64_auxv_t, { .a_type = AT_RANDOM, .a_un.a_val = (uintptr_t)rnd } );
    push(sp, Elf64_auxv_t, { .a_type = AT_NULL } );

    asm volatile(
    "mov $0, %%rdx;" // required by ABI
    "mov %0, %%rsp;"
    "jmp *%1" : : "a"(sp_exec), "b"(h->e_entry));
    }

    int main(int argc, char *argv[], char *envp[]) {
    if (argc < 2) {
    fprintf(stderr, "Usage: %s file [args...]\n", argv[0]);
    exit(1);
    }
    execve_(argv[1], argv + 1, envp);
    }
  • 手动实现了一个应用程序的加载器
  • 先加载了ELF的文件头(header)到内存中
  • picture 10
  • ELF文件中有一些LOAD标记,指示文件中的某个位置的规定大小的部分需要加载到内存中指定的位置上,还原程序执行的初始状态
  • 然后给程序准备初始状态下的栈中的内容
    • picture 11
    • 操作就是分配一个栈大小的变量,然后向其中push变量,所谓push也就是存入东西然后移动指针
    • 然后跳转指针即可

      Linux提供了一个在程序执行的过程中加载共享库的函数

      void* dlopen(const char *filename, int flag);
  • flag可以设置为RTLD_LASY,将代码推迟到执行的时候再符号解析
  • 还提供了一个获取共享库的符号地址的方法
    void* dlsym(void* handle, char* symbol);
  • 其中的handle就是前面打开的共享库
  • symbol就是其中的符号名称
    int dlclose(void* handle);
  • 卸载共享库

    中断

  • 异步中断
    • 不是当前程序引起的,比如I/O操作引起的
  • 同步中断
    • CPU中执行的指令引起的中断
  • picture 3

    程序异常

  • picture 4
  • 所谓的段错误(Segmentation Fault)就是程序试图访问一块未申请的内存空间引起的,也就是图中的13号
  • 18号异常时机器检查,一般是硬件错误引起的
  • picture 5
    • Linux系统调用号对应的是跳转表中内存的偏移量

      fork和execve

  • fork调用一次返回两次
    • 父进程和子进程各一次
  • execve调用一次不返回

    程序执行的传参argv

  • picture 6
    • argv的第一个是可执行文件的名字
    • argc是参数的数量
  • picture 7
    • 环境变量是envp,也类似
    • execve的作用就是调用加载器如图
      • picture 8

        僵尸进程

  • 终止运行但是没有被父进程回收的进程就是zombie进程

    回收进程

    pid_t waitpid(pid_t pid,int *statusp,int options);
  • 如果pid>0,则回收该pid指定的某个确定的进程
  • 如果pid=-1,则回收该进程创建的所有子进程
  • 第二个参数是导致返回的子进程的状态信息(终止的原因是正常返回还是异常终止还是其他)
  • waitpid程序不会按照任何特定顺序回收子进程,除非明确指定

    发送信号

    int kill(pid_t pid,int sig);
  • 如果pid为0,则发送信号给调用进程所在进程组中的所有进程,包括自己
  • 如果pid>0,则发送信号给pid指定的进程
  • 如果pid<0,则发送信号给进程组-pid的每个进程
    unsigned int alarm(unsigned int secs);
  • 进程可以用alarm给自己发送信号,参数是在 secs秒之后给调用进程发送一个SIGALRM
  • secs为0就不会调用新的闹钟了

    信号处理

  • 一个进程最多只有一个同类型的信号,等待处理的信号超过一个的时候会被直接丢弃
  • 处理多个信号
    • picture 9