摘要:为了提升大学生对C语言语法原理的理解,通过对比C语言语句与汇编指令,提出了一种在程序调试时使用汇编指令去理解分析C语言语法原理的方案。首先对C语言可执行程序的运行原理进行了介绍,然后介绍了JFE and GCC软件的调试功能,再然后区分了AT&T与Intel汇编指令的不同之处,最后就一个程序调试的实例进行了汇编指令分析。实验关于赋值语句的机器指令分析,验证了C语言在编译时确定局部变量的地址空间,实验体现了汇编指令分析有效、实用。
关键词:C语言;可执行程序;JFE and GCC;汇编指令;程序调试
中图分类号:TP312 文献标识码:A
文章编号:1009-3044(2020)29-0147-03
《程序设计语言(C)》(或称为《大学计算机A(2)》)是大中专院校大学一年级理工科学生的公共基礎必修课,该课程教学内容知识点繁多、逻辑思维抽象,对于刚进人大学的新生而言,存在一定的难度;教学时间一般为72课时,分18周进行,理论课时36个或40个,实践课时36个或32个。巨同升认为,讲授该门课程时纯粹以语法为导向,耗费了大量时间和精力,教学效果却不理想[1]。马金霞提倡翻转课堂教学法,其中提到“让学生来当老师,让学生主宰课堂”[2],认为这样能够充分调动学生学习的主动性和积极性。当前的C语言程序设计教学存在“以语法为导向”与“以教学手段为导向”这两个主要的方向。本文针对C语言的内涵,挖掘C语言与汇编指令的关联,提出一种在程序调试时使用汇编指令去理解分析C语言语法原理的教学方法。C语言是一种贴近人类自然语言的高级编程语言,C语言编写的源代码在经过编译软件预处理和汇编后,将被转化为汇编指令源代码,分析此汇编源代码,可以更好地理解C语言的语句代码如何展开执行。
1 C语言可执行程序的运行原理
C语言编写的可执行文件这样执行:首先,由C标准库函数(C运行时库)执行“启动代码”;紧接着,载人由程序员所编写代码转化而来的指令序列;然后,指令序列进入maln函数,这个mam函数是一个主控函数,它将按顺序执行指令序列,并且根据mam函数体的具体内容调用其他函数,这些其他函数既有程序员自己编写的函数也有C语言标准库函数;再接着,mam函数将控制权移交给C语言标准库函数,再执行标准库函数中的“关闭代码”;关闭代码执行完毕后,它将控制权移交给操作系统[3】。
2 一个小巧易用的C编译软件JFE and GCC
Jens' File Editor( JFE),是新西兰梅西大学(Massey Univer-sity) Albany校区科学(Science)院计算机科学(Computer Sci-ence)系开发的一款软件,旨在为GCC C/C++编译器提供集成的开发环境。JFE是免费软件,随附的GCC和GDB是开源软件( Open Source Software),编辑器采用g++ 2.95版本,适用于Win-dows系列操作系统(Operating System)下。使用JFE and GCC软件编写C语言源代码,不需要新建工程,只需要在保存时选择一个文件夹把文件保存为*.c文件(*为通配符,表示1到多个字符)。编写好代码,编译通过之后,点击Run命令,Debug执行开始。前进执行一条C语言语句,点击工具栏的Step命令;前进执行一条汇编语句,点击工具栏的Step Asm Inst命令。两个命令结合起来,再打开寄存器窗口和内存窗口,并且在内存窗口左上角的右侧带滚动条的Address文本框中输入相应地址或者点击滚动按钮,可以很方便地观察内存地址中所储存数值的变化。
3 AT&T与Intel汇编指令不同之处
当前,标准C语言运行在32位的保护模式下,分析汇编代码时主要用到的CPU(Central Processing Unit中央处理器)寄存器分别为:4个数据寄存器(EAX、EBX、ECX和EDX),2个变址索引寄存器(ESI和EDI),2个指针寄存器(ESP和EBP),1个指令指针寄存器(EIP),1个标志寄存器(EFlags)。JFE and GCC软件使用AT&T Linux操作系统汇编指令格式,与Intel汇编指令格式大同小异,都是基于X86架构。它们之间的不同表现在:CI)AT&T和Intel格式中的源操作数和目标操作数的位置正好相反,在Intel汇编格式中,目标操作数在源操作数的左边,比如mov ebp,esp 而在AT&T汇编格式中,目标操作数在源操作数的右边,比如mov %esp,%ebp。(2)在AT&T汇编格式中,寄存器名要加上‘%作为前缀,比如push% ebp;而在Intel汇编格式中,寄存器名不需要加前缀,比如push ebp。(3)在AT&T汇编格式中,用‘$前缀表示一个立即操作数,比如mov $0x0,%eax;而在Intel汇编格式中.立即数的表示不用带任何前缀,比如moveax,0。(4)在AT&T汇编格式中,操作数的字长由操作符的最后一个字母决定,后缀‘b、‘w、‘1分别表示操作数为字节(byte,8比特)、字(word,16比特)和长字(long,32比特),比如movb $Ox5,%al;而在Intel汇编格式中,操作数的字长用“byteptr”和“word ptr”等前缀来表示,比如mov al,byte ptr 5。(5)在AT&T汇编格式中,绝对转移和调用指令(jump/call)的操作数前要加上‘*作为前缀,比如汇编指令Ox401780: jmp *Ox4050f0;而在Intel格式中则不需要;远程转移指令和远程子调用指令的操作码,在AT&T汇编格式中为“ljump”和“lcall”,而在Intel汇编格式中则为“jmp far”和“call far”。(6)在AT&T汇编格式中,内存操作数的寻址方式是section: disp(base,index,scale),而在Intel汇编格式中,内存操作数的寻址方式为section:[base+ index*scale+ disp],由于Linux工作在保护模式下,用的是32位线性地址,计算地址时不用考虑段基址和偏移量,计算地址的方法为disp+ base+ index} scalec4]。
4 C语言程序调试分析实例
在此处,调试程序用到了一个叫作GDB (The GNU ProjectDebugger)的工具软件,它是GNU(GNU s Not Unix!的递归缩写)下的项目( Project)调试器(Debugger),遵循自由软件基金会( Free Software Foundation)的GNU通用公共许可证(GNU Gen-eral Public License.GPL),使用它可以查看另一个程序在执行过程中正在执行的操作,或该程序崩溃时正在执行的操作。
程序设计思路:定义两个变量,然后使用调试模式查看其内存的分配。当前测试环境:Windows 7旗舰版64位,内存12GB,JFE软件2004版——内含GNU gdb 6.0。
C语言源代码:int main0{ int i,j; i=l;j=2; return 0;)
在JFE软件环境中调试程序,分三个步骤进行。第一步,在JFE and GCC软件中编辑代码;第二步,点击Compiler菜单下的Compile命令按钮,点击View菜单下的Output命令按钮,查看编译系统的输出信息,若是Output窗口的最后一行出现Suc-cess,则顺利通过编译。第三步,点击Run命令,Debug执行开始,点击Source Window窗口中工具行第一个深蓝色的跑步形状Run按钮,跳出一个窗体内容全为黑色的控制台(Console)窗口,表明调试程序(gdb)已经开始执行,再在当前窗口右上角Source mode下拉按钮中选择MIXED,即可展现程序清单4.1所列代码内容,它们是C语言源代码与指令地址、AT&T汇编代码的混合编排。
4.1 程序清单
1 int main0{
一0x4012d0
: push %ebp
一0x4012dl : mov %esp,%ebp
……(此處省略10行)
一0x4012f5: call Ox401420<一main>
2
inti,j;
3
i=1:j_2;
一0x4012fa: movl$Oxl,Oxfffffffc(%ebp)
一0x401301: movl $Ox2,Oxfffffff8(%ebp)
4
retum 0:
一0x401308: mov $OxO,%eax
一0x40130d: leave
一0x40130e: ret
可以看到gdb软件执行至Ox4012f5处,等待响应。点击工具行芯片图形模样按钮,打开Registers窗口,可以看到,基址寄存器( EBP)的值为Ox28ff58,0x表示采用16进制整数。call 0x401420<一main>中的一mam函数是标准C运行库的一个函数,负责完成库函数的初始化和初始化应用程序执行环境,最后自动跳转到main0,这里可以使用点击Step单步执行C语言跳过其汇编指令执行细节,调试程序执行至Ox4012fa处,点击Memory命令按钮打开内存(Memory)窗口,在内存窗口左上角Address文本框中输入相应地址Ox4012fa后按下回车键,可以看到地址Ox401300(高地址)至Ox4012fa(低地址)地址空间的值Ox 00 00 0001fc 45 c7,其对应的机器指令为Ox c7 45fc 00 00 00叭。数据书写和存储的原则为“高高低低”,即高地址空间存储高(书写在左边)字节数值,低地址空间存储低(书写在右边)字节数值,而指令的存储从低字节到高字节。紧挨在一起的00 00 00叭是一个整体,表示16进制数值Oxl,即10进制数值1.0x c7 45 fc 00 00 00 01是一个机器指令,对应的汇编助词符movl $Oxl,Oxfffffffc(% ebp),打开Intel手册(IntelAn:hitecture Software Developer's Manual Volume 2:InstructionSet Reference,1997),查看指令格式(原文Figure 2-1. Intel Ar-chitecture Instruction Format),再打开手册第3.2章INSTRUC-TION REFERENCE3-286页查看MOV指令,得知这个机器指令采用16进制值c7 10作为第一字节编码,汇编助记符写为Mov rlm32,imm32,解释为Move imm32 to r/m32.imm32表示32位二进制数值,r/m32表示32位宽度的寄存器或内存空间,/0是/digit的具体数值,表示这个机器指令的操作码(Opcode)除了第一字节值c7之外,还有补充的额外3个二进制位作为补充Opcode,这三个位是ModR/M中的Reg/Opcode,包含在第二字节值45之中。第二字节是ModR/M字节,它包含三个域:mod,reg/opcode,r/m。mod占2位,由于这个指令使用寄存器相对寻址,查表得知,mod域的二进制值为01;reg/opcode占3位,由这个指令的第一字节值c7 10看出,当前reg/opcode域是对前面第一字节的Opcode进行补充,所以这里用到的是opcode而不是reg,又由前面的Opcode值c7 10得知opcode值为0,所以reg/opcode域的二进制值为000;r/m占3位,结合机器指令中的16进制值fc(即二进制值11111100),确定在Intel手册第36页中的表格(Table2-2. 32-Bit Addressing Forms with the ModR/M Byte)中应为disp8[EBP]行,又由c7 10中的/0(/digit)确定,应为digit取值为0这列。ModR/M字节16进制值45展开为二进制值01 000 101,完全符合所述分析过程。16进制数值Oxffff fffc翻译成二进制数值为1111 1111 1111 1111 1111 1111 1111 1100.最高的符号位若是1表示负数,若是0表示正数(0只有正0,没有负0),负数在约翰·冯·诺依曼(John von Neumann)结构的微机中采用补码表示,根据补码与原码相互转换的规则:最高(书写在最左边)的符号位不变,取反加1;或者根据补码自身逻辑意义的完整性,不必理会符号位,对所有位取反加1。该数的二进制原码为1000 0000 0000 0000 0000 0000 0000 0100,也可用补码Oxfffffffc减去模Oxl 0000 0000以数学运算方式(速度更快)得到- 0x4(即10进制数值-4)。点击寄存器按钮,查看到基址指针寄存器EBP的值为Ox28ff58,减去Ox4得到Ox28ff54;点击存储器按钮查看Memory状态,在左上角的文本框输入Ox28ff54之后回车,先看看里面存放的值是什么,然后点击工具栏的Step AsmInst按钮让其单步执行一条指令,再次回到Memory窗口查看0x28ff'54开始的4个字节宽度地址空间的值,发现已经变成了0x0000 0001,即已经执行了movl $Oxl,0xfffffffc(% ebp)汇编指令,对照C语言源代码,对应i=l;这条语句,由此知道,i变量的地址为Ox28ff54;此时,调试程序顺序执行至Ox40130l: movl $Ox2,Oxfffffff8(%ebp)这一行,同样的分析可知,起始地址为EBP -8即Ox28ff50开始连续的4个字节地址空间的值将会被修改为Ox2,对应C语言源代码j=2;这条语句。
将C语言源程序修改为int main()( int i,j;j=2; i=l;return0.}即将j的赋值语句放到i的赋值语句之前,同樣地,启动调试程序,摘录混合代码片段如程序清单4.2所示。
4.2 程序清单
Ox4012f5: call Ox401420<一main>
2
inti.j;
3
_j=2; i=l;
一 Ox4012fa: movl $Ox2,Oxfffffff8(%ebp)
一
Ox401301: movl $Oxl,Oxfffffffc(%ebp)
检查发现,交换i,i赋值顺序后,编译系统对i,j的赋值指令不变,即i=l;语句的赋值指令仍为movl$Oxl,0xfffffffc(%ebp),而j=2;语句的赋值指令仍为movl$Ox2,0xfffffffB(%ebp)。这里需要提到程序运行时函数帧栈的原理,栈是程序在运行时进行数据存储的一段动态内存空间,其地址分配从高到低,它采取先进后出的原则对数据进行存取,这里的数据从宏观层面来看就是一个个的函数帧栈,每个函数作为一帧( Frame),依次进入栈( Stack)区,每个帧包含:当前函数的返回地址、函数调用链上个函数的基础地址、局部变量、函数调用链下个函数的实参。C语言程序从源代码文件至可执行文件,需要4个阶段,分别为预处理、汇编、编译、链(连)接,这4个阶段分别可以使用命令行命令来实现,通常在可视化编译软件中使用的编译包含了预处理、汇编和编译这3个阶段。C语言规定,程序需要先编译再连接后运行,程序运行所需空间在编译阶段确定。栈是一块连续的内存区域,栈顶的地址和栈的最大容量由编译系统在编译时确定,栈顶是数据出入的地方。
根据实验,通过对汇编指令的分析,可以确定,编译系统在对函数进行编译时,会按照先定义的变量先分配内存的原则部署函数的局部变量,一旦分配了内存,变量就确定了它所代表的内存空间,这个空间里原来会有一个值,而赋值语句则是修改这个内存空间的值。一条C语句通常对应多条汇编指令,通过逐条分析汇编指令,可以最真实地还原CPU的工作细节。在教师的合理指导下,程序调试过程中出现的汇编指令显得不再是陌生和复杂,学生们不需要额外再进行一个学期的系统学习就能理解与应用。实验证明,对汇编指令的分析,使程序的执行过程显得直观、清晰,帮助学生从硬件的角度确切地明白程序的执行过程。
5 总结
C语言简洁而功能强大,在系统编程、网络通信、硬件驱动、嵌入式硬件编程等方面保持着持久的活力。标准C运行库负责启动代码、转入主程序、关闭代码。查看汇编文件,可以明白地看到代码区、常量区、全局变量区。在运行时,通过调试程序可以查看CPU常用寄存器里面存储的值和内存地址空间中的值,进而可以解析栈区(系统管理)和堆区(编程者管理)的具体情况。JFE and GCC是一个非常好的编译软件,简单、实效,能够很好地帮助编程者了解C语言面向过程编程的细节。调试是每一个程序编写者都必须掌握的技能,带有汇编指令的调试信息,可以更加清晰、直接地明白C语言各种语句运行的机制。通过调试功能,分析汇编指令,从底层去了解C语言的语法概念,能够帮助学习者迅速地拨去迷雾、追踪原理。
参考文献:
[1]巨同升.当前C语言教学中存在的问题及对策[J].电脑知识与技术,2019,15(3):81-82.
[2]马金霞.“翻转课堂”教学法在C语言教学中的应用[Jl.信息与电脑,2019,32(20):250-251.
[3] Jeff Duntemann著梁晓晖译.汇编语言:基于Linux环培[M].北京:清华大学出版社,2014:441-442.
[4]全速前行.AT&T与Intel格式的汇编语法[EB/OL]. (2014-06-27) [2020-05-05l. https://blog. csdn. net/lincyang/article/de-tails/35321687.
[通联编辑:王力]
作者简介:申丽平(1982-),男,湖南邵东人,讲师,硕士,主要研究方向为互联网编程。