蚊章汇 - Eli Bendersky
type: channel
link: https://linuxcn.bwsl.wang/author/569/
description: Eli Bendersky
items: 并发服务器（五）：Redis 案例研究
items: 并发服务器（四）：libuv
items: 并发服务器（三）：事件驱动
items: 并发服务器（二）：线程
items: 并发服务器（一）：简介
items: 调试器工作原理（三）：调试信息
items: 调试器工作原理（二）：断点
items: 调试器工作原理（一）：基础篇

并发服务器（五）：Redis 案例研究
type: item
date: 2018-03-08T03:31:28+08:00
description: 这是我写的并发网络服务器系列文章的第五部分。在前四部分中我们讨论了并发服务器的结构，这篇文章我们将去研究一个在生产系统中大量使用的服务器的案例—— Redis。Redis 是一个非常有魅力的项目，我关注它很久了。它最让我着迷的一点就是它的 C 源代码非常清晰。它也是一个高性能、大并发的内存数据库服务器的非常好的例子，它是研究网络并发服务器的一个非常好的案例，因此，我们不能错过这个好机会。我们来看看前四部分讨论的概念在真实世界中的应用程序。本系列的所有文章有：第一节 - 简介第二节 - 线程第三节 - 事件驱动第四节 - libuv第五节 - Redis 案例研究事件处理库Redis 最初发布于 2009 年，它最牛逼的一件事情大概就是它的速度 —— 它能够处理大量的并发客户端连接。需要特别指出的是，它是用一个单线程来完成的，而且还不对保存在内存中的数据使用任何复杂的锁或者同步机制。Redis 之所以如此牛逼是因为，它在给定的系统上使用了其可用的最快的事件循环，并将它们封装成由它实现的事件循环库（在 Linux 上是 epoll，在 BSD 上是 kqueue，等等）。这个库的名字叫做 ae。ae 使得编写一个快速服务器变得很容易，只要在它内部没有阻塞即可，而 Redis 则保证 注1 了这一点。在这里，我们的兴趣点主要是它对文件事件的支持 —— 当文件描述符（如网络套接字）有一些有趣的未决事情时将调用注册的回调函数。与 libuv 类似，ae 支持多路事件循环（参阅本系列的第三节和第四节）和不应该感到意外的 aeCreateFileEvent 信号：int aeCreateFileEvent(aeEventLoop *eventLoop, int fd, int mask, aeFileProc *proc, void *clientData); 它在 fd 上使用一个给定的事件循环，为新的文件事件注册一个回调（proc）函数。当使用的是 epoll 时，它将调用 epoll_ctl 在文件描述符上添加一个事件（可能是 EPOLLIN、EPOLLOUT、也或许两者都有，取决于 mask 参数）。ae 的 aeProcessEvents 功能是 “运行事件循环和发送回调函数”，它在底层调用了 epoll_wait。处理客户端请求我们通过跟踪 Redis 服务器代码来看一下，ae 如何为客户端事件注册回调函数的。initServer 启动时，通过注册一个回调函数来读取正在监听的套接字上的事件，通过使用回调函数 acceptTcpHandler 来调用 aeCreateFileEvent。当新的连接可用时，这个回调函数被调用。它调用 accept 注2 ，接下来是 acceptCommonHandler，它转而去调用 createClient 以初始化新客户端连接所需要的数据结构。createClient 的工作是去监听来自客户端的入站数据。它将套接字设置为非阻塞模式（一个异步事件循环中的关键因素）并使用 aeCreateFileEvent 去注册另外一个文件事件回调函数以读取事件 —— readQueryFromClient。每当客户端发送数据，这个函数将被事件循环调用。readQueryFromClient 就让我们期望的那样 —— 解析客户端命令和动作，并通过查询和/或操作数据来回复。因为客户端套接字是非阻塞的，所以这个函数必须能够处理 EAGAIN，以及部分数据；从客户端中读取的数据是累积在客户端专用的缓冲区中，而完整的查询可能被分割在回调函数的多个调用当中。将数据发送回客户端在前面的内容中，我说到了 readQueryFromClient 结束了发送给客户端的回复。这在逻辑上是正确的，因为 readQueryFromClient 准备要发送回复，但它不真正去做实质的发送 —— 因为这里并不能保证客户端套接字已经准备好写入/发送数据。我们必须为此使用事件循环机制。Redis 是这样做的，它注册一个 beforeSleep 函数，每次事件循环即将进入休眠时，调用它去等待套接字变得可以读取/写入。beforeSleep 做的其中一件事情就是调用 handleClientsWithPendingWrites。它的作用是通过调用 writeToClient 去尝试立即发送所有可用的回复；如果一些套接字不可用时，那么当套接字可用时，它将注册一个事件循环去调用 sendReplyToClient。这可以被看作为一种优化 —— 如果套接字可用于立即发送数据（一般是 TCP 套接字），这时并不需要注册事件 ——直接发送数据。因为套接字是非阻塞的，它从不会去阻塞循环。为什么 Redis 要实现它自己的事件库？在 第四节 中我们讨论了使用 li…

并发服务器（四）：libuv
type: item
date: 2018-03-02T09:20:53+08:00
description: 这是并发网络服务器系列文章的第四部分。在这一部分中，我们将使用 libuv 再次重写我们的服务器，并且也会讨论关于使用一个线程池在回调中去处理耗时任务。最终，我们去看一下底层的 libuv，花一点时间去学习如何用异步 API 对文件系统阻塞操作进行封装。本系列的所有文章：第一节 - 简介第二节 - 线程第三节 - 事件驱动第四节 - libuv使用 libuv 抽象出事件驱动循环在 第三节 中，我们看到了基于 select 和 epoll 的服务器的相似之处，并且，我说过，在它们之间抽象出细微的差别是件很有吸引力的事。许多库已经做到了这些，所以在这一部分中我将去选一个并使用它。我选的这个库是 libuv，它最初设计用于 Node.js 底层的可移植平台层，并且，后来发现在其它的项目中也有使用。libuv 是用 C 写的，因此，它具有很高的可移植性，非常适用嵌入到像 JavaScript 和 Python 这样的高级语言中。虽然 libuv 为了抽象出底层平台细节已经变成了一个相当大的框架，但它仍然是以 事件循环 思想为中心的。在我们第三部分的事件驱动服务器中，事件循环是显式定义在 main 函数中的；当使用 libuv 时，该循环通常隐藏在库自身中，而用户代码仅需要注册事件句柄（作为一个回调函数）和运行这个循环。此外，libuv 会在给定的平台上使用更快的事件循环实现，对于 Linux 它是 epoll，等等。libuv 支持多路事件循环，因此事件循环在库中是非常重要的；它有一个句柄 —— uv_loop_t，以及创建/杀死/启动/停止循环的函数。也就是说，在这篇文章中，我将仅需要使用 “默认的” 循环，libuv 可通过 uv_default_loop() 提供它；多路循环大多用于多线程事件驱动的服务器，这是一个更高级别的话题，我将留在这一系列文章的以后部分。使用 libuv 的并发服务器为了对 libuv 有一个更深的印象，让我们跳转到我们的可靠协议的服务器，它通过我们的这个系列已经有了一个强大的重新实现。这个服务器的结构与第三部分中的基于 select 和 epoll 的服务器有一些相似之处，因为，它也依赖回调。完整的 示例代码在这里；我们开始设置这个服务器的套接字绑定到一个本地端口：int portnum = 9090; if (argc >= 2) { portnum = atoi(argv[1]); } printf("Serving on port %d\n", portnum); int rc; uv_tcp_t server_stream; if ((rc = uv_tcp_init(uv_default_loop(), &server_stream)) < 0) { die("uv_tcp_init failed: %s", uv_strerror(rc)); } struct sockaddr_in server_address; if ((rc = uv_ip4_addr("0.0.0.0", portnum, &server_address)) < 0) { die("uv_ip4_addr failed: %s", uv_strerror(rc)); } if ((rc = uv_tcp_bind(&server_stream, (const struct sockaddr*)&server_address, 0)) < 0) { die("uv_tcp_bind failed: %s", uv_strerror(rc)); } 除了它被封装进 libuv API 中之外，你看到的是一个相当标准的套接字。在它的返回中，我们取得了一个可工作于任何 libuv 支持的平台上的可移植接口。这些代码也展示了很认真负责的错误处理；多数的 libuv 函数返回一个整数状态，返回一个负数意味着出现了一个错误。在我们的服务器中，我们把这些错误看做致命问题进行处理，但也可以设想一个更优雅的错误恢复。现在，那个套接字已经绑定，是时候去监听它了。这里我们运行首个回调注册：// Listen on the socket for new peers to connect. When a new peer connects, // the on_peer_connected callback will be invoked. if ((rc = uv_listen((uv_stream_t*)&server_stream, N_BACKLOG, on_peer_connected)) < 0) { die("uv_listen failed: %s", uv_strerror(rc)); } uv_listen 注册一个事件回…

并发服务器（三）：事件驱动
type: item
date: 2017-12-08T18:20:00+08:00
description: 这是并发服务器系列的第三节。第一节 介绍了阻塞式编程，第二节：线程 探讨了多线程，将其作为一种可行的方法来实现服务器并发编程。另一种常见的实现并发的方法叫做 事件驱动编程，也可以叫做 异步 编程 注1 。这种方法变化万千，因此我们会从最基本的开始，使用一些基本的 API 而非从封装好的高级方法开始。本系列以后的文章会讲高层次抽象，还有各种混合的方法。本系列的所有文章：第一节 - 简介第二节 - 线程第三节 - 事件驱动阻塞式 vs. 非阻塞式 I/O作为本篇的介绍，我们先讲讲阻塞和非阻塞 I/O 的区别。阻塞式 I/O 更好理解，因为这是我们使用 I/O 相关 API 时的“标准”方式。从套接字接收数据的时候，调用 recv 函数会发生 阻塞，直到它从端口上接收到了来自另一端套接字的数据。这恰恰是第一部分讲到的顺序服务器的问题。因此阻塞式 I/O 存在着固有的性能问题。第二节里我们讲过一种解决方法，就是用多线程。哪怕一个线程的 I/O 阻塞了，别的线程仍然可以使用 CPU 资源。实际上，阻塞 I/O 通常在利用资源方面非常高效，因为线程就等待着 —— 操作系统将线程变成休眠状态，只有满足了线程需要的条件才会被唤醒。非阻塞式 I/O 是另一种思路。把套接字设成非阻塞模式时，调用 recv 时（还有 send，但是我们现在只考虑接收），函数返回的会很快，哪怕没有接收到数据。这时，就会返回一个特殊的错误状态 注2 来通知调用者，此时没有数据传进来。调用者可以去做其他的事情，或者尝试再次调用 recv 函数。示范阻塞式和非阻塞式的 recv 区别的最好方式就是贴一段示例代码。这里有个监听套接字的小程序，一直在 recv 这里阻塞着；当 recv 返回了数据，程序就报告接收到了多少个字节 注3 ：int main(int argc, const char** argv) { setvbuf(stdout, NULL, _IONBF, 0); int portnum = 9988; if (argc >= 2) { portnum = atoi(argv[1]); } printf("Listening on port %d\n", portnum); int sockfd = listen_inet_socket(portnum); struct sockaddr_in peer_addr; socklen_t peer_addr_len = sizeof(peer_addr); int newsockfd = accept(sockfd, (struct sockaddr*)&peer_addr, &peer_addr_len); if (newsockfd < 0) { perror_die("ERROR on accept"); } report_peer_connected(&peer_addr, peer_addr_len); while (1) { uint8_t buf[1024]; printf("Calling recv...\n"); int len = recv(newsockfd, buf, sizeof buf, 0); if (len < 0) { perror_die("recv"); } else if (len == 0) { printf("Peer disconnected; I'm done.\n"); break; } printf("recv returned %d bytes\n", len); } close(newsockfd); close(sockfd); return 0; } 主循环重复调用 recv 并且报告它返回的字节数（记住 recv 返回 0 时，就是客户端断开连接了）。试着运行它，我们会在一个终端里运行这个程序，然后在另一个终端里用 nc 进行连接，发送一些字符，每次发送之间间隔几秒钟：$ nc localhost 9988 hello # wait for 2 seconds after typing this socket world ^D # to end the connection> 监听程序会输出以下内容：$ ./blocking-listener 9988 Listening on port 9988 peer (localhost, 37284) connected Calling recv... recv returned 6 bytes Calling recv... recv returned 13 bytes Calling recv... Peer disconnected; I'm done. 现在试试非阻塞的监听程序的版本。这是代码：int…

并发服务器（二）：线程
type: item
date: 2017-10-26T16:19:00+08:00
description: 这是并发网络服务器系列的第二节。第一节 提出了服务端实现的协议，还有简单的顺序服务器的代码，是这整个系列的基础。这一节里，我们来看看怎么用多线程来实现并发，用 C 实现一个最简单的多线程服务器，和用 Python 实现的线程池。该系列的所有文章：第一节 - 简介第二节 - 线程第三节 - 事件驱动多线程的方法设计并发服务器说起第一节里的顺序服务器的性能，最显而易见的，是在服务器处理客户端连接时，计算机的很多资源都被浪费掉了。尽管假定客户端快速发送完消息，不做任何等待，仍然需要考虑网络通信的开销；网络要比现在的 CPU 慢上百万倍还不止，因此 CPU 运行服务器时会等待接收套接字的流量，而大量的时间都花在完全不必要的等待中。这里是一份示意图，表明顺序时客户端的运行过程：这个图片上有 3 个客户端程序。棱形表示客户端的“到达时间”（即客户端尝试连接服务器的时间）。黑色线条表示“等待时间”（客户端等待服务器真正接受连接所用的时间），有色矩形表示“处理时间”（服务器和客户端使用协议进行交互所用的时间）。有色矩形的末端表示客户端断开连接。上图中，绿色和橘色的客户端尽管紧跟在蓝色客户端之后到达服务器，也要等到服务器处理完蓝色客户端的请求。这时绿色客户端得到响应，橘色的还要等待一段时间。多线程服务器会开启多个控制线程，让操作系统管理 CPU 的并发（使用多个 CPU 核心）。当客户端连接的时候，创建一个线程与之交互，而在主线程中，服务器能够接受其他的客户端连接。下图是该模式的时间轴：每个客户端一个线程，在 C 语言里要用 pthread这篇文章的 第一个示例代码 是一个简单的 “每个客户端一个线程” 的服务器，用 C 语言编写，使用了 phtreads API 用于实现多线程。这里是主循环代码：while (1) { struct sockaddr_in peer_addr; socklen_t peer_addr_len = sizeof(peer_addr); int newsockfd = accept(sockfd, (struct sockaddr*)&peer_addr, &peer_addr_len); if (newsockfd < 0) { perror_die("ERROR on accept"); } report_peer_connected(&peer_addr, peer_addr_len); pthread_t the_thread; thread_config_t* config = (thread_config_t*)malloc(sizeof(*config)); if (!config) { die("OOM"); } config->sockfd = newsockfd; pthread_create(&the_thread, NULL, server_thread, config); // 回收线程 —— 在线程结束的时候，它占用的资源会被回收 // 因为主线程在一直运行，所以它比服务线程存活更久。 pthread_detach(the_thread); } 这是 server_thread 函数：void* server_thread(void* arg) { thread_config_t* config = (thread_config_t*)arg; int sockfd = config->sockfd; free(config); // This cast will work for Linux, but in general casting pthread_id to an 这个类型转换在 Linux 中可以正常运行，但是一般来说将 pthread_id 类型转换成整形不便于移植代码 // integral type isn't portable. unsigned long id = (unsigned long)pthread_self(); printf("Thread %lu created to handle connection with socket %d\n", id, sockfd); serve_connection(sockfd); printf("Thread %lu done\n", id); return 0; } 线程 “configuration” 是作为 thread_config_t 结构体进行传递的：typedef struct { int sockfd; } thread_config_t; 主循环中调用的 pthread_create 产生一个新线程，然后运行 server_thread 函数。这个线程会在 server_thread 返回的时…

并发服务器（一）：简介
type: item
date: 2017-10-25T17:46:00+08:00
description: 这是关于并发网络服务器编程的第一篇教程。我计划测试几个主流的、可以同时处理多个客户端请求的服务器并发模型，基于可扩展性和易实现性对这些模型进行评判。所有的服务器都会监听套接字连接，并且实现一些简单的协议用于与客户端进行通讯。该系列的所有文章：第一节 - 简介第二节 - 线程第三节 - 事件驱动协议该系列教程所用的协议都非常简单，但足以展示并发服务器设计的许多有趣层面。而且这个协议是 有状态的 —— 服务器根据客户端发送的数据改变内部状态，然后根据内部状态产生相应的行为。并非所有的协议都是有状态的 —— 实际上，基于 HTTP 的许多协议是无状态的，但是有状态的协议也是很常见，值得认真讨论。在服务器端看来，这个协议的视图是这样的：总之：服务器等待新客户端的连接；当一个客户端连接的时候，服务器会向该客户端发送一个 * 字符，进入“等待消息”的状态。在该状态下，服务器会忽略客户端发送的所有字符，除非它看到了一个 ^ 字符，这表示一个新消息的开始。这个时候服务器就会转变为“正在通信”的状态，这时它会向客户端回送数据，把收到的所有字符的每个字节加 1 回送给客户端 注1 。当客户端发送了 $ 字符，服务器就会退回到等待新消息的状态。^ 和 $ 字符仅仅用于分隔消息 —— 它们不会被服务器回送。每个状态之后都有个隐藏的箭头指向 “等待客户端” 状态，用于客户端断开连接。因此，客户端要表示“我已经结束”的方法很简单，关掉它那一端的连接就好。显然，这个协议是真实协议的简化版，真实使用的协议一般包含复杂的报文头、转义字符序列（例如让消息体中可以出现 $ 符号），额外的状态变化。但是我们这个协议足以完成期望。另一点：这个系列是介绍性的，并假设客户端都工作的很好（虽然可能运行很慢）；因此没有设置超时，也没有设置特殊的规则来确保服务器不会因为客户端的恶意行为（或是故障）而出现阻塞，导致不能正常结束。顺序服务器这个系列中我们的第一个服务端程序是一个简单的“顺序”服务器，用 C 进行编写，除了标准的 POSIX 中用于套接字的内容以外没有使用其它库。服务器程序是顺序，因为它一次只能处理一个客户端的请求；当有客户端连接时，像之前所说的那样，服务器会进入到状态机中，并且不再监听套接字接受新的客户端连接，直到当前的客户端结束连接。显然这不是并发的，而且即便在很少的负载下也不能服务多个客户端，但它对于我们的讨论很有用，因为我们需要的是一个易于理解的基础。这个服务器的完整代码在这里；接下来，我会着重于一些重点的部分。main 函数里面的外层循环用于监听套接字，以便接受新客户端的连接。一旦有客户端进行连接，就会调用 serve_connection，这个函数中的代码会一直运行，直到客户端断开连接。顺序服务器在循环里调用 accept 用来监听套接字，并接受新连接：while (1) { struct sockaddr_in peer_addr; socklen_t peer_addr_len = sizeof(peer_addr); int newsockfd = accept(sockfd, (struct sockaddr*)&peer_addr, &peer_addr_len); if (newsockfd < 0) { perror_die("ERROR on accept"); } report_peer_connected(&peer_addr, peer_addr_len); serve_connection(newsockfd); printf("peer done\n"); } accept 函数每次都会返回一个新的已连接的套接字，然后服务器调用 serve_connection；注意这是一个 阻塞式 的调用 —— 在 serve_connection 返回前，accept 函数都不会再被调用了；服务器会被阻塞，直到客户端结束连接才能接受新的连接。换句话说，客户端按 顺序 得到响应。这是 serve_connection 函数：typedef enum { WAIT_FOR_MSG, IN_MSG } ProcessingState; void serve_connection(int sockfd) { if (send(sockfd, "*", 1, 0) < 1) { perror_die("send"); } ProcessingState state = WAIT_FOR_MSG; while (1) { uint8_t buf[1024]; int len = recv(sockfd, buf, sizeof buf, 0); if (len < 0) { perror_die("recv"); } else if (len == 0)…

调试器工作原理（三）：调试信息
type: item
date: 2017-06-06T16:16:00+08:00
description: 这是调试器的工作原理系列文章的第三篇。阅读这篇文章之前应当先阅读第一篇与第二篇。这篇文章的主要内容本文将解释调试器是如何在机器码中查找它将 C 语言源代码转换成机器语言代码时所需要的 C 语言函数、变量、与数据。调试信息现代编译器能够将有着各种缩进或嵌套的程序流程、各种数据类型的变量的高级语言代码转换为一大堆称之为机器码的 0/1 数据，这么做的唯一目的是尽可能快的在目标 CPU 上运行程序。通常来说一行 C 语言代码能够转换为若干条机器码。变量被分散在机器码中的各个部分，有的在堆栈中，有的在寄存器中，或者直接被优化掉了。数据结构与对象在机器码中甚至不“存在”，它们只是用于将数据按一定的结构编码存储进缓存。那么调试器怎么知道，当你需要在某个函数入口处暂停时，程序要在哪停下来呢？它怎么知道当你查看某个变量值时，它怎么找到这个值？答案是，调试信息。编译器在生成机器码时同时会生成相应的调试信息。调试信息代表了可执行程序与源代码之间的关系，并以一种提前定义好的格式，同机器码存放在一起。过去的数年里，人们针对不同的平台与可执行文件发明了很多种用于存储这些信息的格式。不过我们这篇文章不会讲这些格式的历史，而是将阐述这些调试信息是如何工作的，所以我们将专注于一些事情，比如 DWARF。DWARF 如今十分广泛的用作 Linux 和类 Unix 平台上的可执行文件的调试格式。ELF 中的 DWARF根据它的维基百科 所描述，虽然 DWARF 是同 ELF 一同设计的（DWARF 是由 DWARF 标准委员会推出的开放标准。上文中展示的图标就来自这个网站。），但 DWARF 在理论上来说也可以嵌入到其他的可执行文件格式中。DWARF 是一种复杂的格式，它吸收了过去许多年各种不同的架构与操作系统的格式的经验。正是因为它解决了一个在任何平台与 ABI （应用二进制接口）上为任意高级语言产生调试信息这样棘手的难题，它也必须很复杂。想要透彻的讲解 DWARF 仅仅是通过这单薄的一篇文章是远远不够的，说实话我也并没有充分地了解 DWARF 到每一个微小的细节，所以我也不能十分透彻的讲解 （如果你感兴趣的话，文末有一些能够帮助你的资源。建议从 DWARF 教程开始上手）。这篇文章中我将以浅显易懂的方式展示 DWARF，以说明调试信息是如何实际工作的。ELF 文件中的调试部分首先让我们看看 DWARF 处在 ELF 文件中的什么位置。ELF 定义了每一个生成的目标文件中的每一节。 节头表 section header table 声明并定义了每一节及其名字。不同的工具以不同的方式处理不同的节，例如连接器会寻找连接器需要的部分，调试器会查找调试器需要的部分。我们本文的实验会使用从这个 C 语言源文件构建的可执行文件，编译成 tracedprog2：#include <stdio.h> void do_stuff(int my_arg)、 { int my_local = my_arg + 2; int i; for (i = 0; i < my_local; ++i) printf("i = %d\n", i); } int main() { do_stuff(2); return 0; } 使用 objdump -h 命令检查 ELF 可执行文件中的 节头 section header ，我们会看到几个以 .debug_ 开头的节，这些就是 DWARF 的调试部分。26 .debug_aranges 00000020 00000000 00000000 00001037 CONTENTS, READONLY, DEBUGGING 27 .debug_pubnames 00000028 00000000 00000000 00001057 CONTENTS, READONLY, DEBUGGING 28 .debug_info 000000cc 00000000 00000000 0000107f CONTENTS, READONLY, DEBUGGING 29 .debug_abbrev 0000008a 00000000 00000000 0000114b CONTENTS, READONLY, DEBUGGING 30 .debug_line 0000006b 00000000 00000000 000011d5 CONTENTS, READONLY, DEBUGGING 31 .debug_frame 00000044 00000000 00000000 00001240 CONTENTS, READONLY, DEBUGGING 32 .debug_str 000000ae 00000000 00000000 00001284 CONTENTS, R…

调试器工作原理（二）：断点
type: item
date: 2017-05-30T00:02:00+08:00
description: 这是调试器工作原理系列文章的第二部分，阅读本文前，请确保你已经读过第一部分。关于本文我将会演示如何在调试器中实现断点。断点是调试的两大利器之一，另一个是可以在被调试进程的内存中检查变量值。我们在系列的第一部分已经了解过值检查，但是断点对我们来说依然神秘。不过本文过后，它们就不再如此了。软件中断为了在 x86 架构机器上实现断点，软件中断（也被称作“陷阱”）被会派上用场。在我们深入细节之前，我想先大致解释一下中断和陷阱的概念。CPU 有一条单独的执行流，一条指令接一条的执行（在更高的层面看是这样的，但是在底层的细节上来说，现在的许多 CPU 都会并行执行多个指令，这其中的一些指令就不是按照原本的顺序执行的）。为了能够处理异步的事件，如 IO 和 硬件定时器，CPU 使用了中断。硬件中断通常是一个特定的电子信号，并附加了一个特别的”响应电路”。该电路通知中断激活，并让 CPU 停止当前执行，保存状态，然后跳转到一个预定义的地址，也就是中断处理程序的位置。当处理程序完成其工作后，CPU 又从之前停止的地方重新恢复运行。软件中断在规则上与硬件相似，但实际操作中有些不同。CPU 支持一些特殊的指令，来允许软件模拟出一个中断。当这样的一个指令被执行时，CPU 像对待一个硬件中断那样 —— 停止正常的执行流，保存状态，然后跳转到一个处理程序。这种“中断”使得许多现代 OS 的惊叹设计得以高效地实现（如任务调度，虚拟内存，内存保护，调试）。许多编程错误（如被 0 除）也被 CPU 当做中断对待，常常也叫做“异常”， 这时候硬件和软件中断之间的界限就模糊了，很难说这种异常到底是硬件中断还是软件中断。但我已经偏离今天主题太远了，所以现在让我们回到断点上来。int 3 理论前面说了很多，现在简单来说断点就是一个部署在 CPU 上的特殊中断，叫 int 3。int 是一个 “中断指令”的 x86 术语，该指令是对一个预定义中断处理的调用。x86 支持 8 位的 int 指令操作数，这决定了中断的数量，所以理论上可以支持 256 个中断。前 32 个中断为 CPU 自己保留，而 int 3 就是本文关注的 —— 它被叫做 “调试器专用中断”。避免更深的解释，我将引用“圣经”里一段话（这里说的“圣经”，当然指的是英特尔的体系结构软件开发者手册, 卷 2A）。INT 3 指令生成一个以字节操作码（CC），用于调用该调试异常处理程序。（这个一字节格式是非常有用的，因为它可以用于使用断点来替换任意指令的第一个字节 ，包括哪些一字节指令，而不会覆写其它代码）上述引用非常重要，但是目前去解释它还是为时过早。本文后面我们会回过头再看。int 3 实践没错，知道事物背后的理论非常不错，不过，这些理论到底意思是啥？我们怎样使用 int 3 部署断点？或者怎么翻译成通用的编程术语 —— 请给我看代码！实际上，实现非常简单。一旦你的程序执行了 int 3 指令， OS 就会停止程序（ OS 是怎么做到像这样停止进程的？ OS 注册其 int 3 的控制程序到 CPU 即可，就这么简单）。在 Linux（这也是本文比较关心的地方） 上， OS 会发送给进程一个信号 —— SIGTRAP。就是这样，真的。现在回想一下本系列的第一部分, 追踪进程（调试程序） 会得到其子进程（或它所连接的被调试进程）所得到的所有信号的通知，接下来你就知道了。就这样, 没有更多的电脑架构基础术语了。该是例子和代码的时候了。手动设置断点现在我要演示在程序里设置断点的代码。我要使用的程序如下：section .text ; The _start symbol must be declared for the linker (ld) global _start _start: ; Prepare arguments for the sys_write system call: ; - eax: system call number (sys_write) ; - ebx: file descriptor (stdout) ; - ecx: pointer to string ; - edx: string length mov edx, len1 mov ecx, msg1 mov ebx, 1 mov eax, 4 ; Execute the sys_write system call int 0x80 ; Now print the other message mov edx, len2 mov ecx, msg2 mov ebx, 1 mov eax, 4 int 0x80 ; Execute sys_exit mov eax, 1 int 0x80 section .data msg1 db 'H…

调试器工作原理（一）：基础篇
type: item
date: 2017-04-18T17:04:00+08:00
description: 这是调试器工作原理系列文章的第一篇，我不确定这个系列会有多少篇文章，会涉及多少话题，但我仍会从这篇基础开始。这一篇会讲什么我将为大家展示 Linux 中调试器的主要构成模块 - ptrace 系统调用。这篇文章所有代码都是基于 32 位 Ubuntu 操作系统。值得注意的是，尽管这些代码是平台相关的，将它们移植到其它平台应该并不困难。缘由为了理解我们要做什么，让我们先考虑下调试器为了完成调试都需要什么资源。调试器可以开始一个进程并调试这个进程，又或者将自己同某个已经存在的进程关联起来。调试器能够单步执行代码，设定断点并且将程序执行到断点，检查变量的值并追踪堆栈。许多调试器有着更高级的特性，例如在调试器的地址空间内执行表达式或者调用函数，甚至可以在进程执行过程中改变代码并观察效果。尽管现代的调试器都十分的复杂（我没有检查，但我确信 gdb 的代码行数至少有六位数），但它们的工作的原理却是十分的简单。调试器的基础是操作系统与编译器 / 链接器提供的一些基础服务，其余的部分只是简单的编程而已。Linux 的调试 - ptraceLinux 调试器中的瑞士军刀便是 ptrace 系统调用（使用 man 2 ptrace 命令可以了解更多）。这是一种复杂却强大的工具，可以允许一个进程控制另外一个进程并从 内部替换 Peek and poke 被控制进程的内核镜像的值（Peek and poke 在系统编程中是很知名的叫法，指的是直接读写内存内容）。接下来会深入分析。执行进程的代码我将编写一个示例，实现一个在“跟踪”模式下运行的进程。在这个模式下，我们将单步执行进程的代码，就像机器码（汇编代码）被 CPU 执行时一样。我将分段展示、讲解示例代码，在文章的末尾也有完整 c 文件的下载链接，你可以编译、执行或者随心所欲的更改。更进一步的计划是实现一段代码，这段代码可以创建可执行用户自定义命令的子进程，同时父进程可以跟踪子进程。首先是主函数：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; } 看起来相当的简单：我们用 fork 创建了一个新的子进程（这篇文章假定读者有一定的 Unix/Linux 编程经验。我假定你知道或至少了解 fork、exec 族函数与 Unix 信号）。if 语句的分支执行子进程（这里称之为 “target”），else if 的分支执行父进程（这里称之为 “debugger”）。下面是 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) { perror("ptrace"); return; } /* Replace this process's image with the given program */ execl(programname, programname, 0); } 这段代码中最值得注意的是 ptrace 调用。在 sys/ptrace.h 中，ptrace 是如下定义的：long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data); 第一个参数是 _request_，这是许多预定义的 PTRACE_* 常量中的一个。第二个参数为请求分配进程 ID。第三个与第四个参数是地址与数据指针，用于操作内存。上面代码段中的 ptrace 调用发起了 PTRACE_TRACEME 请求，这意味着该子进程请求系统内核让其父进程跟踪自己。帮助页面上对于 request 的描述很清楚：意味着该进程被其父进程跟踪。任何传递给该进程的信号（除了 SIGKILL）都将通过 wait() 方法阻塞该进程并通知其父进程。此外，该进程的之后所有调用 exec() 动作都将导致 SIGTR…