了解开发调试基本工具

编辑器

Understand

在OS实验网站上有Understand Windows版的资源。该软件是一个阅读代码的很好工具,可以可视化的看到各个函数之间的调用关系,可以很好的找到函数、变量的定义,具体的使用方法以及介绍可以参考该教程。但是就编辑代码来说,不建议使用Understand。

VScode

VScode是很好的项目管理、代码编译器工具,集成了git,并且可以安装各类插件支持各种语言,习惯使用visual studio的同学使用起来会非常习惯,具体的下载安装使用方法,可以参考该教程,值得说明的是,我们在编译的时候需要其他工具联合编译,因此可以仅仅把VScode当成没有感情的写代码工具,不由它来编译运行,编译运行交给终端。

VScode 快捷键的使用在Windows和Ubuntu上有些不同哦~

写完了代码别忘了格式化代码鸭~看起来好舒服的!

编译器:GCC

在Ubuntu Linux中的C语言编程主要基于GNU C的语法,通过gcc来编译并生成最终执行文件。GNU汇编(assembler)采用的是AT&T汇编格式,Microsoft 汇编采用Intel格式。

编译简单的 C 程序

C 语言经典的入门例子是 Hello World,下面是一示例代码:

#include <stdio.h>
int
main(void)
{
    printf("Hello, world!\n");
    return 0;
}

我们假定该代码存为文件‘hello.c’。要用 gcc 编译该文件,使用下面的命令:

$ gcc -Wall hello.c -o hello

该命令将文件‘hello.c’中的代码编译为机器码并存储在可执行文件 ‘hello’中。机器码的文件名是通过 -o 选项指定的。该选项通常作为命令行中的最后一个参数。如果被省略,输出文件默认为 ‘a.out’。

注意到如果当前目录中与可执行文件重名的文件已经存在,它将被复盖。 选项 -Wall 开启编译器几乎所有常用的警告──强烈建议你始终使用该选项。编译器有很多其他的警告选项,但 -Wall 是最常用的。默认情况下GCC 不会产生任何警告信息。当编写 C 或 C++ 程序时编译器警告非常有助于检测程序存在的问题。

本例中,编译器使用了 -Wall 选项而没产生任何警告,因为示例程序是完全合法的。

要运行该程序,输入可执行文件的路径如下:

$ ./hello
Hello, world!

这将可执行文件载入内存,并使 CPU 开始执行其包含的指令。 路径 ./ 指代当前目录,因此 ./hello 载入并执行当前目录下的可执行文件 ‘hello’。

AT&T汇编基本语法

Ucore中用到的是AT&T格式的汇编,与Intel格式的汇编有一些不同。二者语法上主要有以下几个不同:

    * 寄存器命名原则
        AT&T: %eax                      Intel: eax
    * 源/目的操作数顺序 
        AT&T: movl %eax, %ebx           Intel: mov ebx, eax
    * 常数/立即数的格式 
        AT&T: movl $_value, %ebx        Intel: mov eax, _value
      把value的地址放入eax寄存器
        AT&T: movl $0xd00d, %ebx        Intel: mov ebx, 0xd00d
    * 操作数长度标识 
        AT&T: movw %ax, %bx             Intel: mov bx, ax
    * 寻址方式 
        AT&T:   immed32(basepointer, indexpointer, indexscale)
        Intel:  [basepointer + indexpointer × indexscale + imm32)

如果操作系统工作于保护模式下,用的是32位线性地址,所以在计算地址时不用考虑segment:offset的问题。上式中的地址应为:

    imm32 + basepointer + indexpointer × indexscale

下面是一些例子:

    * 直接寻址 
            AT&T:  foo                         Intel: [foo]
            boo是一个全局变量。注意加上$是表示地址引用,不加是表示值引用。对于局部变量,可以通过堆栈指针引用。

    * 寄存器间接寻址 
            AT&T: (%eax)                        Intel: [eax]

    * 变址寻址 
            AT&T: _variable(%eax)               Intel: [eax + _variable]
            AT&T: _array( ,%eax, 4)             Intel: [eax × 4 + _array]
            AT&T: _array(%ebx, %eax,8)          Intel: [ebx + eax × 8 + _array]

GCC基本内联汇编

GCC 提供了两内内联汇编语句(inline asm statements):基本内联汇编语句(basic inline asm statement)和扩展内联汇编语句(extended inline asm statement)。GCC基本内联汇编很简单,一般是按照下面的格式:

    asm("statements");

例如:

    asm("nop"); asm("cli");

"asm" 和 "asm" 的含义是完全一样的。如果有多行汇编,则每一行都要加上 "\n\t"。其中的 “\n” 是换行符,"\t” 是 tab 符,在每条命令的 结束加这两个符号,是为了让 gcc 把内联汇编代码翻译成一般的汇编代码时能够保证换行和留有一定的空格。对于基本asm语句,GCC编译出来的汇编代码就是双引号里的内容。例如:

        asm( "pushl %eax\n\t"
             "movl $0,%eax\n\t"
             "popl %eax"
        );

实际上gcc在处理汇编时,是要把asm(...)的内容"打印"到汇编文件中,所以格式控制字符是必要的。再例如:

    asm("movl %eax, %ebx");
    asm("xorl %ebx, %edx");
    asm("movl $0, _boo);

在上面的例子中,由于我们在内联汇编中改变了 edx 和 ebx 的值,但是由于 gcc 的特殊的处理方法,即先形成汇编文件,再交给 GAS 去汇编,所以 GAS 并不知道我们已经改变了 edx和 ebx 的值,如果程序的上下文需要 edx 或 ebx 作其他内存单元或变量的暂存,就会产生没有预料的多次赋值,引起严重的后果。对于变量 _boo也存在一样的问题。为了解决这个问题,就要用到扩展 GCC 内联汇编语法。

GCC扩展内联汇编

使用GCC扩展内联汇编的例子如下:

#define read_cr0() ({ \
unsigned int __dummy; \
__asm__( \
    "movl %%cr0,%0\n\t" \
    :"=r" (__dummy)); \
__dummy; \
})

它代表什么含义呢?这需要从其基本格式讲起。GCC扩展内联汇编的基本格式是:

asm [volatile] ( Assembler Template
   : Output Operands
   [ : Input Operands
   [ : Clobbers ] ])

其中,asm 表示汇编代码的开始,其后可以跟 volatile(这是可选项),其含义是避免 “asm” 指令被删除、移动或组合,在执行代码时,如果不希望汇编语句被 gcc 优化而改变位置,就需要在 asm 符号后添加 volatile 关键词:asm volatile(...);或者更详细地说明为:asm volatile(...);然后就是小括弧,括弧中的内容是具体的内联汇编指令代码。 "" 为汇编指令部分,例如,"movl %%cr0,%0\n\t"。数字前加前缀 “%“,如%1,%2等表示使用寄存器的样板操作数。可以使用的操作数总数取决于具体CPU中通用寄存器的数 量,如Intel可以有8个。指令中有几个操作数,就说明有几个变量需要与寄存器结合,由gcc在编译时根据后面输出部分和输入部分的约束条件进行相应的处理。由于这些样板操作数的前缀使用了”%“,因此,在用到具体的寄存器时就在前面加两个“%”,如%%cr0。输出部分(output operand list),用以规定对输出变量(目标操作数)如何与寄存器结合的约束(constraint),输出部分可以有多个约束,互相以逗号分开。每个约束以“=”开头,接着用一个字母来表示操作数的类型,然后是关于变量结合的约束。例如,上例中:

:"=r" (__dummy)

“=r”表示相应的目标操作数(指令部分的%0)可以使用任何一个通用寄存器,并且变量__dummy 存放在这个寄存器中,但如果是:

:“=m”(__dummy)

“=m”就表示相应的目标操作数是存放在内存单元__dummy中。表示约束条件的字母很多,下表给出几个主要的约束字母及其含义:

输入部分(input operand list):输入部分与输出部分相似,但没有“=”。如果输入部分一个操作数所要求使用的寄存器,与前面输出部分某个约束所要求的是同一个寄存器,那就把对应操作数的编号(如“1”,“2”等)放在约束条件中。在后面的例子中,可看到这种情况。修改部分(clobber list,也称 乱码列表):这部分常常以“memory”为约束条件,以表示操作完成后内存中的内容已有改变,如果原来某个寄存器的内容来自内存,那么现在内存中这个单元的内容已经改变。乱码列表通知编译器,有些寄存器或内存因内联汇编块造成乱码,可隐式地破坏了条件寄存器的某些位(字段)。 注意,指令部分为必选项,而输入部分、输出部分及修改部分为可选项,当输入部分存在,而输出部分不存在时,冒号“:”要保留,当“memory”存在时,三个冒号都要保留,例如

#define __cli() __asm__ __volatile__("cli": : :"memory")

下面是一个例子:

int count=1;
int value=1;
int buf[10];
void main()
{
    asm(
        "cld \n\t"
        "rep \n\t"
        "stosl"
    :
    : "c" (count), "a" (value) , "D" (buf)
    );
}

得到的主要汇编代码为:

movl count,%ecx
movl value,%eax
movl buf,%edi
#APP
cld
rep
stosl
#NO_APP

cld,rep,stos这几条语句的功能是向buf中写上count个value值。冒号后的语句指明输入,输出和被改变的寄存器。通过冒号以后的语句,编译器就知道你的指令需要和改变哪些寄存器,从而可以优化寄存器的分配。其中符号"c"(count)指示要把count的值放入ecx寄存器。类似的还有:

a eax
b ebx
c ecx
d edx
S esi
D edi
I 常数值,(0 - 31)
q,r 动态分配的寄存器
g eax,ebx,ecx,edx或内存变量
A 把eax和edx合成一个64位的寄存器(use long longs)

也可以让gcc自己选择合适的寄存器。如下面的例子:

asm("leal (%1,%1,4),%0"
    : "=r" (x)
    : "0" (x)
);

这段代码到的主要汇编代码为:

movl x,%eax
#APP
leal (%eax,%eax,4),%eax
#NO_APP
movl %eax,x

几点说明:

  • [1] 使用q指示编译器从eax, ebx, ecx, edx分配寄存器。 使用r指示编译器从eax, ebx, ecx, edx, esi, edi分配寄存器。

  • [2] 不必把编译器分配的寄存器放入改变的寄存器列表,因为寄存器已经记住了它们。

  • [3] "="是标示输出寄存器,必须这样用。

  • [4] 数字%n的用法:数字表示的寄存器是按照出现和从左到右的顺序映射到用"r"或"q"请求的寄存器.如果要重用"r"或"q"请求的寄存器的话,就可以使用它们。

  • [5] 如果强制使用固定的寄存器的话,如不用%1,而用ebx,则:

asm("leal (%%ebx,%%ebx,4),%0"
    : "=r" (x)
    : "0" (x) 
);

注意要使用两个%,因为一个%的语法已经被%n用掉了。

代码维护

make和Makefile

简介

GNU make(简称make)是一种代码维护工具,在大中型项目中,它将根据程序各个模块的更新情况,自动的维护和生成目标代码。

make命令执行时,需要一个 makefile (或Makefile)文件,以告诉make命令需要怎么样的去编译和链接程序。首先,我们用一个示例来说明makefile的书写规则。以便给大家一个感兴认识。这个示例来源于gnu的make使用手册,在这个示例中,我们的工程有8个c文件,和3个头文件,我们要写一个makefile来告诉make命令如何编译和链接这几个文件。我们的规则是:

  • 如果这个工程没有编译过,那么我们的所有c文件都要编译并被链接。

  • 如果这个工程的某几个c文件被修改,那么我们只编译被修改的c文件,并链接目标程序。

  • 如果这个工程的头文件被改变了,那么我们需要编译引用了这几个头文件的c文件,并链接目标程序。

只要我们的makefile写得够好,所有的这一切,我们只用一个make命令就可以完成,make命令会自动智能地根据当前的文件修改的情况来确定哪些文件需要重编译,从而自己编译所需要的文件和链接目标程序。

makefile的规则

在讲述这个makefile之前,还是让我们先来粗略地看一看makefile的规则。

target ... : prerequisites ...
    command
    ...
    ...

target也就是一个目标文件,可以是object file,也可以是执行文件。还可以是一个标签(label)。prerequisites就是,要生成那个target所需要的文件或是目标。command也就是make需要执行的命令(任意的shell命令)。 这是一个文件的依赖关系,也就是说,target这一个或多个的目标文件依赖于prerequisites中的文件,其生成规则定义在 command中。如果prerequisites中有一个以上的文件比target文件要新,那么command所定义的命令就会被执行。这就是makefile的规则。也就是makefile中最核心的内容。

可以查看GNU手册,或者查看这份中文教程

Git

Git是一个开源的分布式版本控制系统,可以有效、高速地处理从很小到非常大的项目版本管理。Git 是 Linus Torvalds 为了帮助管理 Linux 内核开发而开发的一个开放源码的版本控制软件。Git 与Github不一样哦,Git是工具,而GitHub是可以用Git进行管理的远程仓库。

代码层次

  1. 你目录中的文件是第一层

  2. 缓存区,每次add之后,当前目录中要追踪的文件会作为一个版本会存放在缓存区。注意不是所有的文件。一般一个文件生成之后,会标记为“未追踪”,但是否对其做版本管理还是要选择的。例如一些编译文件就没有必要追踪。对需要做版本管理的问件,用add添加,不需要的用clean删除。

  3. 本地仓库,每次commit之后,缓存区最新的版本就会存放在本地仓库。这里要提及一个HEAD的概念。HEAD是当前的版本指向,每次更新或者回退都会修改HEAD的指向,但对仓库中每一个版本并不会删除。所以即使回退到过去还是有机会回到现在的版本的。

  4. 远程仓库,每次push之后,会将本地仓库中HEAD所指向的版本存放到远程仓库

常用命令

与Github链接

首先我们认为你已经有一个github的账户。

然后我们要建立SSH链接。这是一种通讯的加密协议。我先在我的笔记本上计算一对公钥和私钥,将公钥存储在github中,这样本地就可以通过SSH与github展开加密通讯。

建立方法,输入命令

ssh-keygen -t rsa -C "your_email@youremail.com" //双引号里面是你的常用邮箱

输入之后要输入口令,可以不用输入直接按“enter”一路确认就可以了。然后在账户的根目录(/或者/home/你的账户名,具体取决于你执行上述命令时所采用的账户)查找隐藏目录.ssh/id_rsa.pub文件,将当中内容添加到github中。

这样你就可以通过SSH链接到github中了。但是github作为一个远程仓库,你可以链接这个仓库,并保持同步。但是你不能把本地仓库直接上传到github中去。所以你应该先在github中建立一个对应的仓库,然后再在本地建立一个仓库,将两者进行链接,再去写入文件执行版本管理。所用到的命令有

git remote add origin git@github.com:<用户名>/<仓库名>.git
git pull origin master //因为github建立仓库时会有readme.md文件,先要拷贝一份
git push -u origin master //将本地仓库链接到master分支上,你当然可以链接到其他分支
git push//上传你的本地仓库

还有一种方法不用分两地建库再去链接。你可以只在github上建库,然后clone到本地目录中。

git clone git@github.com:<用户名>/<仓库名>.git

VScode中使用

因为VScode是一个集成工具可以直接在VScode中使用Git,用VScode打开已经配置好的仓库,VScode就可以自动读取里面的内容,然后当进行修改后可以通过VScode直接commit与push。具体的操作可以参考该教程

调试器:GDB

功能

gdb 是功能强大的调试程序,可完成如下的调试任务:

  • 设置断点

  • 监视程序变量的值

  • 程序的单步(step in/step over)执行

  • 显示/修改变量的值

  • 显示/修改寄存器

  • 查看程序的堆栈情况

  • 远程调试

  • 调试线程

在可以使用 gdb 调试程序之前,必须使用 -g 或 –ggdb编译选项编译源文件。运行 gdb 调试程序时通常使用如下的命令:

gdb progname

在 gdb 提示符处键入help,将列出命令的分类,主要的分类有:

  • aliases:命令别名

  • breakpoints:断点定义;

  • data:数据查看;

  • files:指定并查看文件;

  • internals:维护命令;

  • running:程序执行;

  • stack:调用栈查看;

  • status:状态查看;

  • tracepoints:跟踪程序执行。

键入 help 后跟命令的分类名,可获得该类命令的详细清单。

常用命令

窗口相关命令

用gdb查看源代码可以用list命令,但是这个不够灵活。可以使用"layout src"命令,或者按Ctrl-X再按A,就会出现一个窗口可以查看源代码。也可以用使用-tui参数,这样进入gdb里面后就能直接打开代码查看窗口。其他代码窗口相关命令:

示例

下面以一个有错误的例子程序来介绍gdb的使用:

/*bugging.c*/
#include <stdio.h>
#include <stdlib.h>

static char buff [256];
static char* string;
int main ()
{
    printf ("Please input a string: ");
    gets (string);
    printf ("\nYour string is: %s\n", string);
}

这个程序是接受用户的输入,然后将用户的输入打印出来。该程序使用了一个未经过初始化的字符串地址 string,因此,编译并运行之后,将出现 "Segment Fault"错误:

$ gcc -o bugging -g  bugging.c
$ ./bugging
Please input a string: asdf
Segmentation fault (core dumped)

为了查找该程序中出现的问题,我们利用 gdb,并按如下的步骤进行:

[1] 运行 “gdb bugging” ,加载 bugging 可执行文件;

$gdb bugging

[2] 执行装入的 bugging 命令;

(gdb) run

[3] 使用 where 命令查看程序出错的地方;

(gdb) where

[4] 利用 list 命令查看调用 gets 函数附近的代码;

(gdb) list

[5] 在 gdb 中,我们在第 11 行处设置断点,看看是否是在第11行出错;

(gdb) break 11

[6] 程序重新运行到第 11 行处停止,这时程序正常,然后执行单步命令next;

(gdb) next

[7] 程序确实出错,能够导致 gets 函数出错的因素就是变量 string。重新执行测试程,用 print 命令查看 string 的值;

(gdb) run
(gdb) print string
(gdb) $1=0x0

[8] 问题在于string指向的是一个无效指针,修改程序,在10行和11行之间增加一条语句 “string=buff; ”,重新编译程序,然后继续运行,将看到正确的程序运行结果。

最后更新于