跳转到内容

使用 C 和 C++ 的编程语言概念/动态内存管理

来自维基教科书,开放的书籍,开放的世界

当被要求定义一个概念时,数学家往往不会将他们的答案建立在日常生活中的局限性上。集合仅仅是不同元素的集合,不会在它们之间强加隐含的顺序。空集和包含三个或五个元素的集合一样“真实”。可以向集合中添加无限数量的元素。事实上,可以谈论无限集合。

不幸的是,计算机的有限性并没有给计算机科学家带来做出这种大胆主张的奢侈。你不能假设你的数据结构具有无限的大小。更糟糕的是,对时间和/或空间性能的关注通常会迫使你穿上静态数据结构的紧身衣:你的数据结构的大小不能超过某个预定义的值。但是,如果你做了一个糟糕的预测怎么办?简单来说:你的程序崩溃;你用不同的值重新构建程序,并祈祷它不会再次发生。或者,以一种自毁的方式,你的程序分配了从未使用过的内存。

不过,情况并非如此糟糕。你有一个选择:动态内存管理。使用动态内存管理函数,可以根据需要动态地增加和缩减数据结构的大小。这些函数通过影响一个称为 *堆* 或 *自由存储区* 的内存区域来完成它们的工作。

增加数据结构的大小是通过诸如 malloc 或 new 之类的函数完成的。这些函数基本上会向语言运行时的一部分(称为内存分配器)请求内存。如果内存分配器可以满足请求,它将返回指向分配区域的指针(或句柄)。否则,将返回一个特殊值(如 NULLnil)或一个异常对象,指示分配请求失败。

缩减数据结构更有趣。虽然一些编程语言提供了显式释放内存的函数,但一些语言会承担责任并代表程序员进行释放。后一组被称为具有 [自动] *垃圾收集* ([自动] GC)。属于第一组的编程语言的内存分配器期望程序员通过调用诸如 freedelete 之类的函数来采取行动。这对马虎的程序员来说是个坏消息。不释放未使用的内存等同于浪费内存,最终会导致崩溃;随着越来越多的内存被分配而没有被回收,可用内存池会缩小,分配器会发现自己处于无法满足分配请求的情况,这不可避免地会导致上述结果。这种未使用的内存被称为垃圾。相反,过早地释放内存同样是灾难性的。你现在面临着使用不指向有效数据的指针的危险。这样的指针被称为悬空指针。

那么,为什么人们——至少有些人——坚持使用没有垃圾收集的语言?或者,他们不能简单地将这种功能添加到 C 之类的语言中?第一个问题的答案是性能。垃圾收集器作为后台线程(或进程)执行,将在所有其他线程处于等待状态或程序内存不足并且下一个分配请求只能通过一些尚未回收的垃圾来满足时接管。[1] 这是一个耗时的过程,不会在几个机器周期内完成;作为其执行的一部分,垃圾收集器将扫描整个堆内存并将未使用的区域标记为垃圾。这意味着调用垃圾收集器将导致性能大幅下降。另一方面,通过诸如 freedelete 之类的函数显式地解除分配,使程序员能够在内存变得未使用时将其返回。虽然它仍然是一个选项,但程序中没有专门的阶段用于以批发的方式专门花费周期来返回未使用的内存。下图显示了这一点。

Comparative performances of GC systems

至于第二个问题,答案是否定的。能够通过不同的指针引用相同的内存区域,C 中的自由使用强制转换使得分配器无法弄清楚内存的内容。如果此区域最初包含其他指针,现在被视为原始字节的集合怎么办?因此,通过内存跟踪指针以找到未使用的区域,从而进行垃圾收集变得不可能。[2]

在这个例子中,我们为字符字符串提供了一个不透明类型的实现。在前面的介绍(基于对象的编程)之上,该类型表示中的两个指针级别为我们提供了强调正确分配和解除分配顺序的机会。

String.h
#ifndef STRING_H
#define STRING_H

#define SUBSTR_NOT_FOUND -1
#define OUT_OF_MEMORY –2

另一个前向声明及其伴侣 typedef!前者用于预先通知编译器关于我们将要处理的记录类型的存在,而后者提供对这种记录类型实例的句柄。以这种方式操作的类型称为 *不透明类型*;通过指针来访问其实例。

观察到原型中提供的所有形式参数都是“'[常量] 指向 struct _STR'”,而不是“struct _STR”。这样做的原因如下:作为信息隐藏原则的自然结果,我们应该将记录类型表示保留为实现细节:设施的用户无需关心对“如何”(实现)问题的详细答案;她最好集中精力找到对“什么”(接口)问题的答案。这可以通过给她不会改变的东西来实现;可能会改变的东西应该作为实现的一部分,而不是接口的一部分。并且指向记录类型的指针的大小保持不变,即使记录类型本身的大小可能发生变化。

struct _STR;
typedef struct _STR *String;

extern String String_Create(const char*);
extern void String_Destroy(String *this);
extern int String_Compare(const String this, const String);
extern String String_Concat(const String this, const String);
extern int String_Contains(const String this, const String);
extern int String_Containsr(const String this, const String);
extern unsigned int String_Length(const String this);
extern String String_Substring(const String this, unsigned int start, unsigned int length);
extern char* String_Tocharp(const String this);

#endif
String.c
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include "General.h"
#include "utility/String.h"

除了字符串 [字符] 之外,我们的表示还提供了字符串的长度作为成员字段。通过这样做,我们在空间和时间之间进行权衡:当被问到字符串的长度时,我们不是遍历所有字符直到看到终止的 NULL 字符,而是返回此成员字段的值。换句话说,我们更喜欢 *空间中的计算* 而不是 *时间中的计算*。

struct _STR {
  unsigned int length;
  char *str;
};

static String String__Reverse(const String this);

String String_Create(const char* source) {
  String this;
  unsigned int i;

除了静态数据区,数据在程序执行期间始终处于活动状态,以及运行时堆栈,局部于块的变量位于其中;我们提供了一个内存池,我们可以在程序执行期间使用。这个内存区域称为程序的自由存储区或堆。与其他两个区域不同,这个区域由程序员通过动态内存管理函数来管理。

自由存储区内存的一个方面是它是无名的。在自由存储区上分配的对象是通过指针间接操作的。自由存储区的另一个方面是分配的内存是未初始化的。

在运行时分配内存称为动态内存分配;通过诸如 malloc 之类的函数分配的内存被称为动态分配的。但是,这并不意味着指针本身的存储空间是动态分配的。它可能很可能是静态分配的。[3]

定义:对象的生存期——程序执行期间存储绑定到对象的这段时间——被称为对象的 *范围*。

在文件范围内定义的变量被称为具有 *静态范围*。存储在程序启动之前分配,并在整个程序执行过程中保持绑定到变量。在局部范围内定义的变量被称为具有局部范围。存储在进入局部范围时在运行时堆栈上分配;退出时,此存储空间被释放。另一方面,静态局部变量表现出静态范围。

在自由存储区上分配的对象被称为具有 *动态范围*。通过使用诸如 C 中的 malloc 之类的函数分配的存储空间将一直绑定到对象,直到由程序员显式地解除分配。

malloc 返回一个通用指针,void*<>/source. Such a pointer cannot be dereferenced with the <syntaxhighlight lang="c" enclose="none">* 或下标运算符。为了将此指针用于生产性目的,我们必须将返回值强制转换为某个特定的指针类型。假设 malloc 可以满足请求,局部变量 this 包含一个地址值,该值将被解释为指示 struct _STR 对象的起始地址。否则,它包含一个多态值,NULL,表示 malloc 的失败。

问题
假设使用“数据级结构”一章中介绍的方案,您认为由 malloc 分配的区域所需的对齐方式是多少?
  this = (String) malloc(sizeof(struct _STR));

如果堆中可能没有足够的内存,NULL 将由 malloc 函数返回。我们应该让用户知道这一点。

成功分配用于元数据的内存后,该元数据是字符串内容的指针及其长度的组合,我们必须为字符串内容分配空间。我们通过第二次使用 malloc 函数来实现这一点。最终,如果一切顺利,我们将得到下面给出的数字。[4]

  if (this == NULL) {
    fprintf(stderr, "Out of memory...\n");
    return(NULL);
  } /* end of if(this == NULL) */

  this->length = strlen(source);
  this->str = (char *) malloc(this->length + 1);
  if (this->str == NULL) {
  free(this);
    fprintf(stderr, "Out of memory...\n");
    return(NULL);
  } /* end of if (this->str == NULL) */

下一个循环以及接下来的两行用于将一串字符复制到另一个内存区域。我们首先在 for 循环中复制与字符串长度一样多的字符,然后追加终止的 NULL 字符。最后,现在我们已在过程中移动了指针,并且它指向字符串末尾的 NULL 字符,我们使用最后一行中的指针运算使其指向该字符的开头。

请注意,我们可以使用标准字符串函数 strcpy 来实现这一点。除了 strcpy 之外,我们还提供了一系列字符串函数。除了这些之外,由于一串字符是以“\0”作为哨兵值的内存区域,因此我们可以使用适当的内存函数处理这样的实体。下面列出了一些此类函数的示例。

朋友们 strcpy
char* strcat(char *dest, const char *src) src 追加到 dest 并返回累积在 dest 中的值作为其结果。
char* strncat(char *dest, const char *src, size_t len) strcat 相同,但附加条件是将累积的字符串长度限制为 len
int strcmp(const char *s1, const char *s2) 返回按字典顺序比较其参数的结果。如果两个字符串相等,则返回值为 0。如果 s1s2 之前,则返回值小于 0。否则,将返回正值。
int strncmp(const char *s1, const char *s2, size_t len) strcmp 相同,但附加条件是将比较限制为参数字符串的前 len 个字符。
char* strcpy(char *dest, const char *src) src 复制到 dest 并返回 dest 的最终值作为其结果。
char* strncpy(char *dest, const char *src, size_t len) strncpy 相同,但附加条件是将复制的字符数限制为 len
size_t strlen(const char *s) 返回其参数的长度。
char* strchr(const char* s, int c): 在字符串 s 中搜索 c 的首次出现。如果成功,将返回指向此位置的指针。否则,将返回一个空指针。
char* strrchr(const char* s, int c): 反向搜索字符串 sc 的首次出现。也就是说,它找到 s 中的最后一个 c。需要注意的一点是:strchrstrrchr 认为终止的空字符是 s 的一部分,用于搜索目的。
char* strstr(const char* str, const char* sub): str 中找到 sub 的首次出现并返回指向此出现位置的开头的指针。如果 sub 不出现在 str 中,则返回空指针。
int memcmp(const void *ptr1, const void *ptr2, size_t len) /* 使用 set 中的字符作为分隔符对 str 进行标记 */
void* memcpy(void *dest, const void *src, size_t len) /* 使用 set 中的字符作为分隔符对 str 进行标记 */
void* memchr(const void *ptr, int val, size_t len ) /* 使用 set 中的字符作为分隔符对 str 进行标记 */
  for (i = 0; i < this->length; i++) 
    *this->str++ = *source++;
  *this->str = '\0';
  this->str -= this->length;

  return(this);
} /* end of String String_Create(const char*) */

现在是时候释放由 *this 指向的内存区域并将它返回到空闲存储中以供重复使用。在 C 中,这可以通过几个函数来完成,其中之一是 free

我们的析构函数诚然非常简单。事实证明,堆内存是 String 数据类型使用的唯一资源。对于其他数据类型可能并非如此。它们可能保存对文件、信号量等资源的句柄。

给定struct _STR的定义,我们有以下内存布局


*this被称为句柄。使用指针(struct _STR *),换句话说,间接寻址,是必要的,以便将用户与表示的潜在变化隔离开。即使实现者改变了底层表示,用户也不会受到影响。[5] 因为她无法直接访问表示。她拥有的只是一个“String对象的句柄”,而不是String对象本身。

指针指向的区域只有在该区域中所有指针指向的内容都被释放后才能被释放。这就是为什么我们应该首先释放(*this)->str,然后释放*this。有一点需要注意:被释放的是指针指向的区域,而不是指针本身!

当控制到达 return 语句时,我们得到下面的图。阴影区域表示返回给分配器的内存;这些区域可以在随后的分配请求中被重新使用。因此,尝试使用已释放的内存是不明智的。


观察析构函数的签名:唯一的参数类型是String *,而不是String。这种修改是为了确保将NULL赋值给已删除对象的句柄是永久性的。我们在析构函数中进行此赋值的原因是,以减轻用户(s)在调用析构函数后必须进行此赋值的负担,因为 - 由于有许多用户关心模块提供的功能,而只有一个实现者应该关心模块的实现方式 - 这无疑会更容易出错。[6]

void String_Destroy(String* this) {
  if (*this == NULL) return;

  free((*this)->str);
  (*this)->str = NULL;
  free(*this);

  *this = NULL;
} /* end of void String_Destroy(String*) */

int String_Compare(const String this, const String str2) {
  return(strcmp(this->str, str2->str));
} /* end of int String_Compare(const String, const String) */

String String_Concat(const String this, const String str2) {
  String res_str;

  res_str = (String) malloc(sizeof(struct _STR));
  if (!res_str) {
    fprintf(stderr, "Out of memory...\n");
    return(NULL);
  } /* end of if (!res_str) */


我们的表示有两层:一层用于底层字符串的信息,一层用于字符串本身。这意味着我们需要发出两个独立的内存分配命令,每个层级一个。因此,在程序中的这个点,我们有上面的内存布局。lengthstr 字段中存在随机值,这些值可能是之前使用分配给它们的区域时留下的。

  res_str->length = this->length + str2->length;
  res_str->str = (char *) malloc(res_str->length + 1);

  if (!res_str->str) {
    free(res_str);
    fprintf(stderr, "Out of memory...\n");
    return(NULL);
  } /* end of if (!res_str->str)*/

当控制到达此点时,我们将已经为 length 字段分配了准确的值,并分配了足够多的内存来保存两个参数的连接。注意,str 指向的内容是随机的。但这并不意味着它们不能被使用。将 strlen 函数应用于 str 仍然会产生合法的返回值,具体取决于第一个“/0”在内存中出现的位置。


  while (*this->str) *res_str->str++ = *this->str++;


最后,我们将第一个参数复制到结果String中。好吧,几乎!每次我们将一个字符从this->str复制到res_str->str时,我们都会将指针向前移动,使其指向下一个要复制的字符。当我们到达源String 的末尾 (this->str) 时,这两个指针都将比它们最初的内容大源字符串的长度。对于res_str->str 来说,这是可以的,因为还有第二个字符串要附加到它。但是,this->str 的原始值需要被恢复,这可以通过以下复合赋值语句来完成。

如果我们使用一个临时指针指向与this->str 相同的位置,并使用这个临时指针复制源String,我们就可以免去这种重新调整。此代码如下所示

String String_Concat(const String this, const String str2) { String res_str; char* temp_str = this->str; ... ... while (*temp_str) *res_str->str++ = *temp_str++; ... ... } /* end of String String_Concat(const String, const String) */

  this->str -= this->length;

  while (*str2->str) *res_str->str++ = *str2->str++;
  str2->str -= str2->length;
  *res_str->str = '\0';
  res_str->str -= res_str->length;

  return(res_str);

将参数Strings 复制到结果String 中后,我们现在返回到调用者。请注意,我们返回指向堆中某个对象的指针。这使我们有机会在函数调用之间共享同一个对象,而如果我们选择使用驻留在运行时栈中的对象,我们将无法做到这一点:驻留在运行时栈中的对象只能在创建它的函数以及从该函数直接或间接调用的函数中访问。

这可能会让我们认为,返回指向静态数据区域或主函数帧中某个对象的指针是安全的选择。就对象的生命周期而言,这是正确的。[7] 但现在我们面临着静态分配带来的限制:在这些区域中分配的对象不能动态地改变大小。在静态数据区域中创建的对象的大小在编译时是固定的,而运行时栈中创建的对象的大小必须在它的定义被展开时固定。这意味着,实现动态数据结构的唯一方法是使用堆内存。[8], [9]


} /* end of String String_Concat(const String, const String) */

int String_Contains(const String this, const String substr) {
  int i;
  unsigned int j;

  for(i = 0; i <= this->length - substr->length; i++) { 
    for (j = 0; j < substr->length; j++) 
      if (substr->str[j] != this->str[i + j]) break;
    if (j == substr->length) return(i);
  } /* end of outer for loop */

  return(SUBSTR_NOT_FOUND);
} /* end of int String_Contains(const String, const String) */

int String_Containsr(const String this, const String substr) {
  String this_rv = String__Reverse(this);
  String sub_rv = String__Reverse(substr);
  int where;

  if (this_rv == NULL || sub_rv == NULL) {
    fprintf(stderr, "Out of memory...\n");
    return(OUT_OF_MEMORY);
  } /* end of if (this_rv == NULL || sub_rv == NULL) */

反向搜索可以用正向搜索来表达。这正是我们在这里所做的。我们在反转后的字符串中对反转后的子字符串进行正向搜索,并根据搜索结果返回一个值。


  where = String_Contains(this_rv, sub_rv);

  String_Destroy(&sub_rv);
  String_Destroy(&this_rv);
  if (where >= 0) return(this->length - substr->length - where);
    else return(SUBSTR_NOT_FOUND);
} /* end of int String_Containsr(const String, const String) */

unsigned int String_Length(const String this) {
  return(this->length);
} /* end of unsigned int String_Length(const String) */

String String_Substring(const String this, unsigned int start, unsigned int len) {
  String res_str;
  unsigned int i;

  if (start >= this->length || len == 0) return(String_Create(""));
  if (start + len > this->length) len = this->length - start;
  res_str = (String) malloc(sizeof(struct _STR));
  if (res_str == NULL) {
    fprintf(stderr, "Out of memory...\n");
    return(NULL);
  } /* end of if (res_str == NULL) */
  res_str->str = (char *) malloc(len + 1);
  if (res_str->str == NULL) {
    free(res_str);
    fprintf(stderr, "Out of memory...\n");
    return(NULL);
  } /* end of if (res_str->str == NULL) */

  for (i = 0; i < len; i++)
    res_str->str[i] = this->str[start + i];
  res_str->str[i] = '\0';
  res_str->length = len;

  return(res_str);
} /* end of String String_Substring(const String, unsigned int, unsigned int) */

以下是我们所说的用户定义的转换函数:它将String 转换为char*。与 C++ 不同,C++ 中编译器会隐式调用此类函数,此转换函数必须由程序员显式调用。

char* String_Tocharp(const String this) {
  char *res_str = (char *) malloc(this->length + 1);
  if (!res_str) {
    fprintf(stderr, "Out of memory...\n");
    return(NULL);
  } /* end of if(!res_str) */
  strcpy(res_str, this->str);

  return(res_str);
} /* end of char* String_Tocharp(const String) */

static String String__Reverse(const String this) {
  String str_reverse;
  unsigned int i;

  str_reverse = (String) malloc(sizeof(struct _STR));
  if (!str_reverse) {
    fprintf(stderr, "Out of memory...\n");
    return(NULL);
  } /* end of if (!str_reverse) */
  str_reverse->str = (char *) malloc(this->length + 1);
	
  if (!str_reverse->str) {
    free(str_reverse);
    fprintf(stderr, "Out of memory...\n");
    return(NULL);
  } /* end of if (!str_reverse) */
  str_reverse->length = this->length;

  for (i = 0; i < this->length; i++)
    str_reverse->str[i] = this->str[this->length - 1 - i];
  str_reverse->str[this->length] = '\0';

  return(str_reverse); 
} /* end of String String__Reverse(const String) */

测试程序

[edit | edit source]
String_Test.c
#include <stdio.h>
#include <stdlib.h>

#include "General.h"
#include "utility/String.h"

int main(void) {
  char *sztmp;
  int loc;
  String first_name, last_name, name;
  String strtmp, strtmp2;

  first_name = String_Create("Tevfik");

为了打印 String 变量的内容,我们将它转换为 C 风格的字符字符串,并将该字符串传递给 printf 函数。但我们必须注意避免创建垃圾。如果我们在不将其存储在一个变量中,直接将 String_Tocharp 的返回值(指向 char 的指针)传递给 printf,那么当 printf 返回时,该指针就会丢失。这将使此指针指向的内存区域变成垃圾。这是我们不希望看到的!出于这个原因,我们首先将返回值赋值给某个临时变量,然后将其发送给 printf 函数。一旦 printf 返回,我们就会使用 free 释放临时变量指向的区域。

  printf("First name: %s", (sztmp = String_Tocharp(first_name)));
  printf("\tLength: %d\n", String_Length(first_name));
  free(sztmp);

  last_name = String_Create("Aktuglu");
  printf("Last name: %s", (sztmp = String_Tocharp(last_name)));
  printf("\tLength: %d\n", String_Length(last_name));
  free(sztmp);

  printf("Forward search for u in the last name: ");
  loc = String_Contains(last_name, strtmp = String_Create("u"));
  if (loc  == SUBSTR_NOT_FOUND)
    printf("u not found...Sth wrong with the String_Contains function!!!\n");
    else printf("u found at location %d\n", loc);
  String_Destroy(&strtmp);

  printf("Backward search for u in the last name: ");
  loc = String_Containsr(last_name, strtmp = String_Create("u"));
  if (loc == SUBSTR_NOT_FOUND)
    printf("u not found...Sth wrong with the String_Containsr function!!!\n");
    else printf("u found at location %d\n", loc);
  String_Destroy(&strtmp);

  name = String_Concat((strtmp = String_Concat(first_name, (strtmp2 = String_Create(" ")))), last_name);
  String_Destroy(&strtmp); String_Destroy(&strtmp2);
  printf("Name: %s", (sztmp = String_Tocharp(name)));
  printf("\tLength: %d\n", String_Length(name));
  free(sztmp);

  strtmp = String_Substring(name, 0, String_Length(first_name));
  printf("Comparing first name with the first %d characters of the name...",  String_Length(first_name));
  if (String_Compare(first_name, strtmp) == 0) printf("Equal\n");
    else printf("Not equal\n");

  String_Destroy(&strtmp); String_Destroy(&first_name);
  String_Destroy(&last_name); String_Destroy(&name);

  exit(0);
} /* end of int main(void) */

使用 GDB 调试程序

[edit | edit source]

一个编译并链接没有错误的程序并不一定意味着一切正常。 逻辑错误可能已经潜入程序,并且我们的可执行文件可能会产生错误的结果。 在这种情况下,我们应该返回源代码并尝试修复故障。 这种方法的问题在于难以确定从何处开始搜索恶意错误。 如果我们正在处理一个涉及数百个函数交互的大型项目,而所讨论的逻辑错误出现在远离其起源的地方怎么办? 我们最好找到一个更简单的方法!

调试器是我们解决问题的答案。 它允许我们逐步执行有问题的软件,动态修改代码,并查明潜在的故障点。 以下是 GNU 调试器 GDB 的介绍。

编译用于调试的源程序

[edit | edit source]

为了调试程序,您需要在编译程序时生成调试信息。 此调试信息存储在目标文件中; 它包含描述每个变量和函数的类型、源代码行号与可执行代码中的地址之间的对应关系等元数据。

要请求将调试信息插入到目标代码中,您必须在运行编译器时指定“-g”选项。 因此,String_Test.c 将由以下命令运行

gcc –c –g –ID:\include String.c[10]
gcc –o String_Test.exe –g –ID:\include String_Test.c
String_Test

[在继续之前,应该强调的是,由于某些优化会涉及代码消除/修改,这意味着您调试的源代码与调试器显示的内容可能不匹配,因此启用优化以及调试选项不是一个好主意。 因此,建议您首先关闭优化(并打开调试开关),然后,当您确信代码经过全面测试并且可以上市时,打开优化。]

如果程序中可能出现问题,请发出下一行给出的命令。

gdb String_Test.exe

此命令将使您进入 GDB 环境,并等待您发出 GDB 命令。 您还可以调试崩溃的进程,并尝试通过传递进程的 core dump[11] 作为额外参数来找出问题所在。 还可以监控正在运行的进程。 因此,gdb 命令可以概括如下

gdb [''executable_file'' [''core_dump'' | ''process_id'']]

从上述规范可以看出,gdb 可以不带任何参数发出。 在这种情况下,您必须通过 GDB 命令提供参数值。

gdb String_Test.exe
gdb
GNU gdb 5.0
Copyright 2000 Free Software Foundation. Inc.
...
(gdb)file String_Test.exe

类似地,可以使用core-file 命令提供 core dump,GDB 可以使用attach 命令附加到进程。

主要 GDB 命令

[edit | edit source]

命令是单行输入,没有长度限制。 它以命令名称开头,后面跟着参数,参数的含义取决于命令名称。

GDB 命令名称始终可以缩写,只要该缩写是明确的。 对于常用的命令,一个没有歧义解释的缩写肯定会执行预期命令。 [12]

将空行作为输入输入到 GDB(只输入 RET)通常意味着重复上一个命令。 # 用于开始单行注释; 从 # 到行尾的任何内容都被视为注释,因此被丢弃。

从 GDB 获取帮助

[edit | edit source]

在 GDB 中有如此多的命令,人们可能难以记住某个特定的命令。 在这种情况下,您可以向 GDB 询问其命令的信息。 您需要了解的是help[13] 命令,它有三种形式

help
显示命令类的简短列表。
help command_class
显示在特定command_class 中找到的命令列表。
help command
显示有关如何使用特定命令的简短段落。
apropos reg_exp
在所有 GDB 命令及其文档中搜索reg_exp 中指定的正则表达式。 例如,
(gdb) apropos local var*↵
backtrace -- 打印所有堆栈帧的回溯
bt -- 打印所有堆栈帧的回溯
info locals -- 当前堆栈帧的局部变量
info locals -- 当前堆栈帧的局部变量
where -- 打印所有堆栈帧的回溯
complete command_prefix
列出所有以command_prefix 开头的可能命令。

GDB 可以为您在命令中填入单词的其余部分,如果只有一种可能性; 它还可以随时向您显示命令中下一个单词的可能性。 为了让 gdb 进行命令补全,您必须按 TAB 键。 如果没有歧义,gdb 将填入单词并等待您完成命令。 否则,它将发出警报,告诉您可能性。 在这一点上,您可以要么提供更多字符并再次尝试,要么只按两次 TAB 键。 按两次 TAB 键将显示以您输入的前缀开头的所有命令名称,这实际上是 complete 命令提供的。 例如,

(gdb) h TAB TAB
handle hbreak help
(gdb) he TAB → (gdb) help

停止执行

[edit | edit source]

除非您另行说明,否则程序启动后将运行至完成。 对于正在调试的程序,这种行为不太有帮助。 我们应该能够在某些点停止以检查程序状态,遍历程序语句,并可能通过干扰程序的控制流来修改代码。

可以在程序的某些点停止,方法是设置断点。 在 GDB 中,这是通过break 命令完成的。 break 可以以多种不同的方式使用。 [14]

break (func_name | filename:func_name)
在名为func_name 的函数的入口处设置断点 [文件名为filename]。
break (line_num | filename:line_num)
在第line_num 行设置断点 [文件名为filename]。
break *address
在地址address 处设置断点。 * 前缀用于表示后面的是要解释为地址值。
break (+ | -) offset
使用参数值作为偏移量,此命令在当前源代码行相对于当前源代码行的位置设置断点。
break
在要执行的下一条指令处设置断点。 等同于break +0.
break location if condition
在某个位置设置断点,该位置可以使用不同的方式提供,如上所述。 只有当condition 评估为非零值时,GDB 才会接管程序(即程序将停止)。 请注意,断点的无条件版本可以使用始终评估为非零值的条件来表示。 也就是说,以前的断点命令等同于break location if n,其中n 是一个非零值。

上述一组替代用法可以简洁地定义为

break [location] [if condition]

其中 [ 和 ] 用于表示封闭的语法单元的零个或一个实例。

tbreak location [if condition]
设置一个临时断点,该断点将在程序第一次在指定为参数的位置停止后被删除。 通常,您会在程序的入口点设置这样的断点。
hbreak location [if condition]
设置硬件辅助断点。 由于缺乏硬件支持,GDB 可能无法设置此类断点。 即使支持,用于此目的的寄存器数量可能不足以满足您的需求。 在这种情况下,您必须删除或禁用未使用的断点,并将其重新用于新的断点。
thbreak location [if condition]
设置一个临时的硬件辅助断点。
rbreak reg_exp
在与正则表达式reg_exp 匹配的所有函数上设置断点。 请记住,您提供的正则表达式有一个隐式的“.*”前导和尾随。 因此,在调试 String_Test.exe 时,
rbreak .*Cont.*↵ 和 rbreak Cont↵
是等价的,它们都将在 String_Contains 处设置断点,并且
String_Containsr
.

观察点是一种特殊的断点,当表达式的值更改时,它会停止程序。 这使您免受预测此类更改可能发生的场所的负担。 它有三种形式

watch expr
设置一个观察点,只要程序写入expr 并更改其值,它就会中断。
rwatch expr
设置一个观察点,只要程序读取expr,它就会中断。
awatch expr
设置一个监视点,当程序访问(读取或写入)expr 时,它将中断。

捕获点 是一种特殊的断点,当发生特定类型的事件时,它会停止您的程序,例如抛出 C++ 异常或加载库。

无条件断点,无论是断点、监视点还是捕获点,都可以通过condition命令转换为条件断点。

condition breakpoint_num [expression]
expression 作为条件添加到由breakpoint_num 指定的断点。如果没有表达式部分,则将删除为断点设置的任何条件。也就是说,它将成为一个普通的无条件断点。

这里,断点号是一个索引,用于引用特定的断点。可以通过发出info breakpoints命令找到它。

可以为任何断点提供一系列命令,在程序因该断点而停止时执行。

commands [breakpoint_num]
command1
command2
...
commandn
end
缺少断点号会导致命令附加到最后设置的断点(不是遇到的断点!)。在commands 和断点号后加上 end 可以轻松地从断点中删除命令列表。

commands 的效果可以通过display 命令部分获得,该命令将它的参数表达式添加到一个称为自动显示列表中。每次程序停止时都会打印此列表中的所有项目。

在确定了程序中的问题后,我们可能希望删除断点。这可以通过使用cleardelete 命令来完成。

clear
删除在即将执行的指令处设置的断点。
clear (function | filename:function)
删除在作为参数传递的函数入口处设置的断点。
clear (line_num | filename:line_num)
删除在指定行代码处或代码内设置的任何断点。
delete [breakpoints] [list_of_breakpoints]
删除作为参数传递的断点。如果没有参数,它将删除所有断点。

与其删除断点,我们可能会选择忽略或禁用它。这样的断点将存在但无效,等待恢复。

ignore breakpoint_num ignore_count
导致由breakpoint_num 引用断点被绕过ignore_count 次。
disable [breakpoints] [list_of_breakpoints]
导致给定的断点被忽略,直到相关联的启用命令。如果没有提供列表,则所有断点都将被禁用。
enable [breakpoints] [list_of_breakpoints]
激活之前禁用的断点列表。
enable [breakpoints] once list_of_breakpoints
仅激活给定的断点列表一次。
enable [breakpoints] delete list_of_breakpoints
激活给定的断点列表以工作一次,然后停止。一旦程序在该断点处停止,GDB 将删除列表中的任何断点。

恢复执行

[edit | edit source]

一旦程序停止,它可以通过不同的方式恢复。以下是这些命令的列表

next [no_of_repetitions]
继续到下一行源代码。如果要执行的当前行包含函数调用,则它将在单个步骤中执行,而不会进入它。换句话说,它永远不会增加运行时堆栈的深度。提供的参数告诉 GDB 执行next 命令的次数。
step [no_of_repetitions]
继续到下一行源代码。与next 不同,如果要执行的当前行包含函数调用,则将插入一个新的堆栈帧,并且控制权将流入被调用函数中的第一个可执行语句。
nexti [no_of_repetitions]
执行下一条机器指令并返回调试器。如果下一条指令是函数调用,则它会执行整个函数,然后返回。
stepi [no_of_repetitions]
执行下一条机器指令并返回调试器。
continue [ignore_counts]
继续运行程序直到下一个断点。当传递一个参数时,它意味着调试器将忽略在最近遇到的位置设置的断点ignore_count - 1 次。因此,continue 等效于continue 1。
until [location]
当传递一个参数时,until 会继续运行程序,直到达到指定的 location 或当前函数结束。当不带参数使用时,location 被假定为当前行。它非常有用,可以避免逐步遍历循环。
finish
继续运行当前函数直到完成。
return [ret_value]
立即返回调用方,而不执行被调用方(即当前函数)中剩余的语句。如果它是一个返回值函数,则ret_value 被解释为被调用方的返回值。
call expr
评估作为其唯一参数传递的表达式,而不改变当前位置。请注意,由于评估导致的副作用是永久性的。
jump (line_no | *address)
在参数中指定的行或地址处恢复执行。

下图显示了这些命令的效果。虚线用于表示状态发生变化,其中状态转换的效果是通过访问所有中间状态来实现的。例如,“完成 f1”表示执行 f1 中剩余的语句,然后返回,这由虚线表示。“从 f2 返回”由一条直线表示,表示它跳过 f2 中的所有语句并将控制权返回到 main

State transition diagram for major debugging commands

跟踪正在运行的程序

[edit | edit source]

有时,停止程序的行为本身可能会影响程序的正确性,或者可能会降低服务质量。前者的例子是实时程序,例如嵌入式系统中的程序;后者的例子是应该全天候运行的服务器。在这种情况下,我们可能会选择使用 GDB 中的tracecollect 命令。使用这些命令和其他一些命令,我们可以指定程序中的位置,并导致在这些位置发生某些操作,例如数据收集。这些位置称为跟踪点。稍后检查在跟踪点收集的数据,以查看程序中出现了什么问题。

trace location
在指定位置设置一个跟踪点。设置跟踪点或更改其命令直到下一个tstart 命令才生效。
tstart
启动跟踪实验并开始收集数据。
tstatus
显示当前跟踪数据收集的状态。
info tracepoints [tracepoint_num]
显示有关特定跟踪点的信息。如果没有参数,它将显示有关迄今为止定义的所有跟踪点的信息。在其他信息项中,它提供了每个跟踪点的系统分配号。
actions [tracepoint_num]
定义在命中编号为tracepoint_num 的跟踪点时要执行的操作列表。如果没有参数,将使用最近定义的跟踪点。
action 块有一些特殊的命令。这些命令是collectwhile-stepping
collect expr [, expression_list]
命中跟踪点时收集给定表达式(s)的值。除了根据源语言语法规则形成的表达式外,还可以使用以下之一。
$regs 收集所有寄存器。
$args 收集所有函数参数。
$locals 收集所有局部变量。
while-stepping n
在跟踪点之后执行n 次单步跟踪,在每一步收集新数据。

一旦收集到足够的数据,我们可以显式地停止实验(而不是程序),或者让 GDB 使用每个跟踪点的通过次数自行停止。

tstop
结束跟踪实验并停止收集数据。
passcount [n [tracepoint_num]]
设置跟踪点的通过次数。通过次数是跟踪点可以击中的最大次数。因此,将跟踪点的通过次数设置为n 将在第n 次命中特定跟踪点时或之前停止跟踪实验。其他跟踪点被击中的次数以及它们的通过次数是多少,第一个被击中的跟踪点,其次数与其通过次数一样多,将停止跟踪实验。缺少跟踪点号假定最近定义的跟踪点。如果没有通过次数值,跟踪实验将继续进行,直到用户显式停止。

每次命中跟踪点时收集的数据称为快照。这些快照从零开始连续编号,并进入缓冲区,以便您稍后检查它们。

tfind snaphot_num
检索编号为snapshot_num 的跟踪快照。参数可以通过以下几种方式指定。
start
查找缓冲区中的第一个快照。它是 0 的同义词。
none
停止调试快照并恢复实时调试。
end
与 none 相同。
ENTER
查找缓冲区中的下一个快照。
-
查找缓冲区中的上一个快照。
tracepoint_num
查找与tracepoint_num 关联的下一个快照。
pc addr
查找其 pc(程序计数器)值为addr 的下一个快照。
outside addr1, addr2
查找其 pc 在给定范围之外的下一个快照。
line [filename
]n
查找与特定源代码行关联的下一个快照。

跟踪实验通常涉及以下步骤。

  1. 使用trace 命令设置跟踪点。
  2. 使用actions,将操作列表附加到跟踪点。
  3. 如果需要,为跟踪点设置通过次数。
  4. 对所有其他数据收集点重复 1-3。
  5. 通过使用以下方法启动跟踪实验tstart.
  6. 使用tstop停止跟踪实验,或者等待跟踪点到达其(跟踪点)的计数来停止跟踪实验。
  7. 使用tfind检查实验期间收集的值。

检查程序

[编辑 | 编辑源代码]

每次被调试的程序停止时,gdb都会打印要执行的行。这可能不足以让你了解上下文。在这种情况下,你需要获取当前行周围的程序行的列表。你可以通过使用list命令来实现这一点。

list [行号]
当传递参数时,list会打印以参数指定的行号为中心的几行。这个值可以通过给出[特定文件中的]行号、[特定文件中的]函数名、地址或偏移量来指定。[15] 不带参数时,list打印最后打印的行之后的几行。传递+作为参数与不传递参数的效果相同。传递-作为参数会列出最后打印的行之前的几行。
list 起始行 , 结束行
当传递两个参数时,list会打印起始行和结束行之间的源代码行。[16] 如果其中一个参数缺失,且逗号仍然存在,则该缺失的值将由另一个参数确定。

除了列出源文件的某些部分之外,你可能还希望查看变量的值或评估表达式。

print [/格式] 表达式
评估表达式并使用格式打印结果。格式规范可以是以下任一格式
d 将值解释为整数并以有符号十进制形式打印。
u 将值解释为整数并以无符号十进制形式打印。
x 将值解释为整数并以十六进制形式打印。
o 将值解释为整数并以八进制形式打印。
t 将值解释为整数并以二进制形式打印。
c 将值解释为整数并以字符常量形式打印。
a 将值视为地址并打印。
f 作为浮点值打印。
如果缺少格式规范,gdb将使用适当的类型。inspect是该命令的同义词。
表达式可以根据源语言的语法规则来形成。我们可以使用某些运算符来扩展这种语法。@将内存的一部分视为数组;::允许你指定标识符的作用域。例如,假设seqint*
print *sum_all::seq@10
seq指向的内存区域(它属于名为sum_all的函数)视为大小为10的数组并打印该数组。

每次表达式被评估和打印时,它也会被保存到一个称为值历史记录的地方。值历史记录中的值可以通过在打印顺序之前加上$符号来引用。第一个被打印的值是$1;第二个值是$2等等。单个$引用最近打印的值,而$$引用前一个值。$$n引用从末尾开始的第n个值。因此,$$0等效于$。需要记住的是,值历史记录保存表达式的值,而不是表达式本身。也就是说,假设*name的值为"Mete",那么在打印*name后存储到值历史记录中的是"Mete",而不是"*name"。

可以使用values子命令来检查值历史记录中的值,该子命令属于show命令。

show values [ n | + ]
打印以n为中心的十个值历史记录条目。如果未传递参数,则打印最后十个值。如果提供+作为参数,则该命令会打印最后打印的值之后的十个历史记录值。

如果你不想将评估结果记录到值历史记录中,应该使用x命令。

x [[/重复次数 格式 单位] 地址]
检查从地址开始的内存,独立于底层实际数据类型。除了在print中使用的那些之外,格式可以是s(表示字符串)或i(表示机器指令)。重复次数单位用于确定要显示的内存量:重复次数 * 单位字节重复次数是一个十进制值,而单位可以是以下任一单位
b 字节。
h 半字(两个字节)。
w 字(四个字节)。
g 巨字(八个字节)。
注意,任何一个参数都可能缺失。格式最初默认为x,并在每次使用xprint时更改。重复次数默认为1。为特定值指定的单位大小将被接受为下次使用相同格式时的默认值。

当你的程序停止时,你可能想知道它在哪里停止以及它是如何到达那里的。这需要有关运行时堆栈的信息,这些信息由以下命令提供。注意,这些命令不会更改程序的控制流。也就是说,在执行以下命令组合之后发出next仍然会从最近命中的断点继续执行。换句话说,以下命令都不会修改指令指针。

frame [帧号 | 地址]
将你从一个堆栈帧移动到另一个堆栈帧,并打印有关你选择的堆栈帧的信息。可以通过传递帧的地址或编号来选择堆栈帧。帧号是一个非负数,其中当前执行的帧为0,它的调用者为1,依此类推。
backtrace [[(- | +)] 帧数]
打印程序到达当前位置的摘要。不带参数使用时,该命令会打印整个堆栈的回溯;带正数时,它会打印最内层(最近)的n个堆栈帧;带负数作为参数时,它会打印最不最近的n个堆栈帧。该命令的同义词是info stack
up [n]
向上移动n个帧。也就是说,它向较不最近创建的帧移动。
down [n]
向下移动n个帧。也就是说,它向较最近的堆栈帧移动。

除了这些之外,我们还可以通过使用info子命令来获取更多信息。

info frame [地址]
打印帧的描述,而不选择它。
info args
打印所选帧的参数。
info locals
打印所选帧的局部变量。
info variables [正则表达式]
显示程序中使用的所有全局变量和静态变量名称。如果传递参数,它会显示所有匹配正则表达式的这些信息项。
info scope (函数名 | *地址 | 行号)
列出某个作用域的局部变量。此列表包含参数和局部变量相对于帧基址的地址。

具有图形用户界面的调试器

[编辑 | 编辑源代码]

如果你不想使用上一节中介绍的[不完整!]命令列表带来的额外灵活性,你可以尝试使用以下调试器之一,它们基本上是在gdb之上添加了一个图形界面。

调试器 描述
rhide 这个开发环境可以在djgpp和cygwin中使用。在任何一个环境的命令提示符下键入rhide,会进入一个dos窗口。注意,这是一个完整的IDE,它恰好包含一个基于gdb的调试器。
gnu ddd 除了linux发行版之外,这个调试器还可以从cygwin中使用。要从cygwin命令提示符启动这个调试器,你需要先发出一个xinit命令。之后,输入ddd会打开一个图形调试器窗口。
insight 随红帽linux发行版一起提供,这个调试器可以通过在命令提示符下输入insight来启动。
kdbg 是suse linux发行版的一部分,这个调试器可以通过在命令提示符下输入kdbg来启动。

除了这些之外,你还可以尝试使用gnu emacs、xemacs、eclipse、kdevelop、netbeans和ms visual studio,它们也提供集成调试支持。最后需要记住的一点是,并非所有调试器/环境都支持所有调试格式。例如,包含gdb调试信息的执行文件对于ms visual studio来说毫无意义。

  1. 有一个很少使用的选项,可以显式调用垃圾收集器。
  2. 但是,有诸如lint或splint之类的工具可以令人满意地用于此目的。
  3. 例如,静态分配的指针可以考虑列表的头部。 动态分配的指针的示例可以考虑同一列表中的下一个节点链接信息。 但是,无论指针是静态分配还是动态分配,以及它指向的是静态数据区域还是运行时堆栈,有一点是肯定的:动态分配的内存是通过指针进行操作的。
  4. 请注意,传递给String_Create函数的参数可能指向三个区域中的任何一个区域的内存。
  5. 假设您通过函数使用底层对象。
  6. 有关后一种方法的示例,请参阅基于对象的编程章节。
  7. 毕竟,前者具有静态范围,而后者在进入main时分配并在退出时解除分配,这实际上等同于第一种情况。
  8. 我们仍然可以通过定义非常大的静态数据结构并使用此[静态结构]作为我们的堆来模拟动态数据结构。 实际上,这种方法用于在诸如90年前的FORTRAN和COBOL之类的编程语言中实现动态结构。 除了无法提供100%的溢出免疫力外,这种方法对内存的浪费相当大。
  9. 请注意,该图未准确反映C调用约定。 由于运行时堆栈中的区域将被调用者丢弃,因此现在就划掉下半部分为时过早。
  10. 应该注意的是,此命令和接下来的两个命令可能是由两个不同的方发出的:分别为实现者和用户。 不预测调试的可能性,或者[更可能]不愿意提供有关实现的信息,实现者可能不会为您提供对象模块的调试版本。 在这种情况下,当控制流指向模块中找到的某个函数时,程序的源代码级调试是不可能的。
  11. 核心转储是包含崩溃时随机存取内存内容的磁盘文件,该文件可以稍后用于找出故障原因。
  12. 在我们的演示中,命令缩写将通过下划线相关命令的部分来表示。
  13. 在本讲义的其余部分,我们将对命令的一部分进行下划线,以表示代表该命令的最短可能前缀。
  14. ( , ), [, ], | 是元字符;它们不是用于形成传递给命令的参数的语法的一部分。 ( 和 ) 用于分组;[ 和 ] 用于表示选项;| 用于分隔备选方案。
  15. 偏移值将添加到打印的最后一行。
  16. 与偏移值一起使用时,第二个参数将添加到将第一个参数添加到要打印的行中获得的值中。
华夏公益教科书