linux c程序编译过程 & 程序虚拟内存布局 & glibc分配 & linux调试器工作原理

2022-06-06

PS:更新于2023-7-7
其实glibc的源码之前就看过了,但是没有做笔记,也没有细看,之后就过去了。现在准备重新捡起来看一遍。

但是在分析glibc源码前,我认为有一些前置内容还是比较重要的。

这里提前讲一下

编译

编译是将 程序 从 源代码 转换为 机器代码 的过程

编译过程中经历了

预处理==>编译==>汇编==>链接

共4个过程

为了方便讲述 下面用一个程序来实操一下

1.预处理

首先编写一个简单的c程序

预处理的命令是 cpp,使用cpp命令对程序进行预处理

cpp test.c -o test.i

也可以用其他的命令

gcc -E test.c > test.i 

预处理完了,看看它生成的东西。

可以看见预处理后的东西很多

其实里面的大多数都是stdio.h库中的函数

你自己写的就在最后一行

2.编译

用如下命令对刚刚生成的 .i 文件进行编译 生成汇编代码

gcc -S test.i

它会在目录下生成 test.s文件

3.汇编

把汇编代码转成目标代码的过程 叫做汇编

as test.s -o test.o

也可以用其他的命令

gcc -c main.s

这时候生成的文件是不可以执行的,因为它还没有链接进 stdio.h中的库函数的代码,还有一些其他的东西

4.链接

链接test.o ,生成可执行文件

gcc -v test.o

也可以使用ld命令来链接动态库。

虚拟内存布局

一个程序在装载到内存中运行的时候,大致的结构是这样的

  • text segment: 一般是存放可执行代码的区域
  • data segment: 存放一些已经初始化的静态变量
  • BSS segment : 存放一些未初始化的静态变量
  • heap : 即堆的位置
  • memory mapping segment: 这个是文件映射到内存中的位置,动态库就是被加载到这里的。一些匿名映射(比如mmap,分配的也是这个区域的内存)
  • stack : 栈空间
  • kernel space: 内核区域

一般情况下,这些段都是不连续的。

malloc底层: brk和mmap

1.brk

malloc是glibc的库函数,它最终也是通过系统调用(s)brk和mmap来完成的。

通过current_bk=sbrk(0)获得当前heap的末尾, 然后通过brk(current_bk+4096)扩展heap,注意,heap的扩展是向高地址扩展。

2.mmap

mmap分配的内存在memory mapping segment部分。因为它是向下增长,所以分配的地址空间都在memory mapping segment的低地址处。

多线程malloc

1.主线程malloc

主线程malloc,会通过brk初始化,最终分配在heap段

2.子线程malloc

子线程malloc,会通过mmap分配一个初始堆,这一点与主线程不同。

64位上,其实heap分配了4M,只不过就0x21000字节是可读可写的。

linux调试器工作原理

当用户请求的内存大于 128KB 时,并且没有任何 arena 有足够的空间时,那么系统就会执行 mmap 函数来分配相应的内存空间。这与这个请求来自于主线程还是从线程无关。

还有值得一提的点子线程的stack和主线程的stack分配的位置是很接近的,这一点要小心,可能会发生爆栈行为。

gdb这个鼎鼎大名的调试器,想必大家都听说过。
gdb之所以能这么方便的跟踪进程,离不开linux提供的系统调用(ptrace)

1. ptrace——linux调试器的瑞士军刀

这是一种复杂、强大的工具,它允许一个进程控制另外一个进程并从内部替换被控制进程的内核镜像的值(也叫Peek adn poke,指直接读写内存内容)
ptrace原型(sys/ptrace.h头文件中定义)

long ptrace(enum __ptrace_request request, \
    pid_t pid,void *addr,void *data); 
//request 是内置选项,主要有 PTRACE_TRACEME PTRACE_ATTACH PTRACE_CONT三个选项。
// pid 就是你要操控或者报告的pid
//第三个与第四个参数是地址与数据指针,用于操作内存

// pid的语义根据request的变化会发生变化
//PTRACE_TRACEME主要是子进程发出的,pid是0,子进程的所有信号,即使信号是忽略处理的(除SIGKILL之外),都将使其停止,父进程将通过wait()获知这一情况
//PTRACE_ATTACH attach到一个指定的进程,使其成为当前进程跟踪的子进程(pid就是要跟踪的进程号),而子进程的行为等同于它进行了一次PTRACE_TRACEME操作。但是,需要注意的是,虽然当前进程成为被跟踪进程的父进程,但是子进程使用getppid()的到的仍将是其原始父进程的pid。
//PTRACE_CONT:继续运行之前停止的子进程。可同时向子进程交付指定的信号。

2. 实例讲解

实例来源于 土制调试器源码

int main(int argc, char** argv)
{
    pid_t child_pid;

    if (argc < 2) {
        fprintf(stderr, "Expected a program name as argument\n");
        return -1;
    }

    child_pid = fork();
    if (child_pid == 0)
        run_target(argv[1]);
    else if (child_pid > 0)
        run_debugger(child_pid);
    else {
        perror("fork");
        return -1;
    }

    return 0;
}

main函数其实很显眼,输入一个你要跟踪的程序名称
然后允许fork()创建子进程(父进程返回的child_pid是子进程的pid,子进程返回的child_pid是0)
子进程允许run_target函数,父进程允许run_debugger函数。

接下来看一下run_target函数

void run_target(const char* programname)
{
    procmsg("target started. will run '%s'\n", programname); //单纯的输出一些信息,没有什么用处

    /* Allow tracing of this process */
    if (ptrace(PTRACE_TRACEME, 0, 0, 0) < 0) { // 调用ptrace(PTRACE_TRACEME, 0, 0, 0),希望父进程能够跟踪自己。
        /*
        关于PTRACE_TRACEME的官方解释:
        意味着该进程被其父进程跟踪。任何传递给该进程的信号(除了 SIGKILL)都将通过 wait() 方法阻塞该进程并通知其父进程。此外,该进程的之后所有调用 exec() 动作都将导致 SIGTRAP 信号发送到此进程上,使得父进程在新的程序执行前得到取得控制权的机会。如果一个进程并不需要它的的父进程跟踪它,那么这个进程不应该发送这个请求。(pid、addr 与 data 暂且不提)
        */
        perror("ptrace");
        return;
    }

    /* Replace this process's image with the given program */
    execl(programname, programname, 0);//exec函数族,会把子进程的内存镜像替换成目标程序。
}

接下来看一下run_debugger函数的源码

void run_debugger(pid_t child_pid)
{
    int wait_status;
    unsigned icounter = 0;
    procmsg("debugger started\n");

    /* Wait for child to stop on its first instruction */
    wait(&wait_status);//等待被跟踪进程发送的信号

    while (WIFSTOPPED(wait_status)) {//如果信号是stop
        //getchar();
        icounter++;
        struct user_regs_struct regs;
        ptrace(PTRACE_GETREGS, child_pid, 0, &regs);//读取子进程的当前寄存器值,然后写入到regs变量中
        unsigned instr = ptrace(PTRACE_PEEKTEXT, child_pid, regs.rip, 0);//读取子进程regs.rip地址,返回该地址的一字的内容。

        procmsg("icounter = %u.  EIP = 0x%lx.  instr = 0x%lx\n",
                    icounter, regs.rip, instr);

        /* Make the child execute another instruction */
        if (ptrace(PTRACE_SINGLESTEP, child_pid, 0, 0) < 0) {//告诉子进程运行一步(一条汇编指令)后停止。
            perror("ptrace");
            return;
        }

        /* Wait for child to stop on its next instruction */
        wait(&wait_status);//继续等待新的信号
    }

    procmsg("the child executed %u instructions\n", icounter);
}

3. 土制调试器源码

我借鉴了这个github上的样例,做了一些修改,使得它可以运行在x64系统上 https://github.com/eliben/code-for-blog/blob/master/2011/simple_tracer.c

/* Code sample: using ptrace for simple tracing of a child process.
**
** Note: this was originally developed for a 32-bit x86 Linux system; some
** changes may be required to port to x86-64.
**
** Eli Bendersky (https://eli.thegreenplace.net)
** This code is in the public domain.
*/
#include <stdio.h>
#include <stdarg.h>
#include <stdlib.h>
#include <signal.h>
#include <syscall.h>
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/reg.h>
#include <sys/user.h>//gdb专用库,十分方便
#include <unistd.h>
#include <errno.h>


/* Print a message to stdout, prefixed by the process ID
*/
void procmsg(const char* format, ...)
{
    va_list ap;
    fprintf(stdout, "[%d] ", getpid());
    va_start(ap, format);
    vfprintf(stdout, format, ap);
    va_end(ap);
}


void run_target(const char* programname)
{
    procmsg("target started. will run '%s'\n", programname);

    /* Allow tracing of this process */
    if (ptrace(PTRACE_TRACEME, 0, 0, 0) < 0) {
        perror("ptrace");
        return;
    }

    /* Replace this process's image with the given program */
    execl(programname, programname, 0);
}


void run_debugger(pid_t child_pid)
{
    int wait_status;
    unsigned icounter = 0;
    procmsg("debugger started\n");

    /* Wait for child to stop on its first instruction */
    wait(&wait_status);

    while (WIFSTOPPED(wait_status)) {
        //getchar();
        icounter++;
        struct user_regs_struct regs;
        ptrace(PTRACE_GETREGS, child_pid, 0, &regs);
        unsigned instr = ptrace(PTRACE_PEEKTEXT, child_pid, regs.rip, 0);

        procmsg("icounter = %u.  EIP = 0x%lx.  instr = 0x%lx\n",
                    icounter, regs.rip, instr);

        /* Make the child execute another instruction */
        if (ptrace(PTRACE_SINGLESTEP, child_pid, 0, 0) < 0) {
            perror("ptrace");
            return;
        }

        /* Wait for child to stop on its next instruction */
        wait(&wait_status);
    }

    procmsg("the child executed %u instructions\n", icounter);
}


int main(int argc, char** argv)
{
    pid_t child_pid;

    if (argc < 2) {
        fprintf(stderr, "Expected a program name as argument\n");
        return -1;
    }

    child_pid = fork();
    if (child_pid == 0)
        run_target(argv[1]);
    else if (child_pid > 0)
        run_debugger(child_pid);
    else {
        perror("fork");
        return -1;
    }

    return 0;
}

reference

https://www.imooc.com/article/251632
https://ctf-wiki.org/pwn/linux/user-mode/heap/ptmalloc2/heap-overview/#sbrk