本节必须掌握的知识点:
示例一源代码
编译、执行和注释
代码分析
1.3.1 示例一
■第一步:新建项目
点击VS菜单“文件”>“添加”>“新建项目”,创建新项目MyProjectOne。接着解决方案管理器窗口看到新建的解决方案“MyProjectOne”。如图1-21所示。
图1-21新建项目
■第二步:新建源文件
接着在项目工程中编写第一个程序。选中“源文件”文件夹,点击鼠标右键,选择“添加”>“新建项”,填写源文件名称“OneDay.c”,设置源文件保存路径D:\code\asm_to_c\MyProjectOne\chapter1\helloworld。
■第三步:编写源代码
剩下的事情就是编写C语言源代码文件了。源代码编辑窗口输入以下代码。
/*
显示Hello World!
*/
#include <stdio.h>//C标准库输入输出头文件
int main(void)//函数头
{
printf("Hello World!");//控制台窗口打印字符串
return 0;//结束返回
}
提示
1.在编写C语言程序时,注意C语言是区分大小写和全角半角字符的,切记要在半角英文状态下编写代码,不然程序会报错。
2.代码书写过程中,请务必严格按照代码书写的规范编写代码【附录C】,这是对一个合格的程序员最基本的要求!
3.设置项目和源文件保存路径,可以自定义。
4.VS中创建源文件默认后缀名为C++源代码文件后缀“.cpp”,需要修改为C语言源文件后缀名“.c”。
5.C语言采用缩进方式表示上下文从属关系,大括号内的语句块缩进。
6.C语言语句使用分号表示语句结束。
1.3.2 编译、执行和注释
■编译
按快捷键F7编译源程序,如图1-22所示。
图1-22编译源程序
如“Hello World!”程序代码所示,通过字符序列创建的程序称为源程序(Source Program)。存放源程序的文件称为源文件(Source File)。在OneDay.c文件中写的字符序列,称为代码(Code)。代码经过预处理、编译、链接,最终生成的二进制可执行exe文件,称为程序(Program)。生成的exe文件是给用户使用的,而源代码是留给程序员自己保留的。
■执行程序
按Ctrl+F5运行程序,会弹出一个黑色窗口,如图1-23所示,控制台窗口输出字符串
“Hello World!”。我们的第一个程序 “Hello World!”,诞生了!别看代码仅有6行,但它涵盖了很多知识点。
图1-23控制台窗口输出结果
通过字符序列创建的程序,需要转化为计算机能够理解的二进制位序列(0和1)。
一般一个程序的完成通常需要经过编写源程序、编译、链接、调试几个步骤,如图1-24所示:
图1-24 程序编译链接过程
■注释
注释可以帮助程序员理解和维护源程序。添加必要的注释是程序员良好习惯之一。在“Hello World!”程序中,使用了多行注释/**/,在C语言程序中/*和*/之间的部分,编译器将中间的文字称为注释。编译源程序时会自动忽略注释内容。
在C语言中,有两种注释方式:
●第一种注释方式://(单行注释)。如果只有“//”开头的语句,编译器认为在“//”这一行的文字称为注释。
例:
#include <stdio.h>
int main(void)
{
//单行注释,这一行为注释内容,对程序的运行是不受影响的。
printf("Hello World!");
return 0;
}
●第二种注释方式:/**/(多行注释)。如果需要注释的语句有多行,可以用/*和*/,把需要注释的内容写在/*和*/之间。
例:
/*
多行注释,在/*和*/之间的内容,编译器视为注释。
在/*和*/之间的内容 ,对程序的运行是不受影响的。
*/
#include <stdio.h>
int main(void)
{
printf("Hello World!");
return 0;
}
总结
不管是单行注释“//”还是多行注释“/**/”,在注释里面写的任何内容,它们都不影响代码的运行,注释的作用是为了程序员阅读起来方便,相当于备注的作用。
1.3.3 代码分析
■代码框架
我们编写的第一个程序“Hello World!”:
#include <stdio.h>
int main(void)
{
printf("Hello World!");
return 0;
}
去掉代码“printf("Hello World!");”这一行代码,变为如下代码:
#include <stdio.h>
int main(void)
{
return 0;
}
以上短短的5行代码可以视为代码框架。
■头文件
#include <stdio.h>是一条预处理指令。
’#’为预处理指令前缀。
include是预处理指令,正式编译源程序之前,先做预处理,将stdio.h头文件复制到此处。
<stdio.h>中的’<>’表示此头文件为C标准库定义的头文件,如果是自定义的头文件,则使用双引号,如”help.h”。 stdio.h为标准库输入输出头文件,包含与输入输出相关的函数声明、全局变量、数据类型和常量值。程序“Hello World!”调用的C语言标准库函数printf的原型声明就是在stdio.h头文件中。当编译函数printf时,因为已经在stdio.h头文件中将printf函数声明为外部C标准库函数,因此先将printf函数地址空下,待链接时,从C语言标准库中添加printf函数地址和函数定义。VS环境目录已经包含C标准库头文件目录和lib库目录。
解决方案资源管理器窗口选中项目MyProjectOne,鼠标右键“属性”>“配置属性”>VC++目录,打开VC++目录窗口如图1-25所示,方框内的包含目录即include头文件目录,库目录即lib库目录。
图1-25 C标准库include头文件目录和lib库目录
实验一:屏蔽stdio.h头文件
如果源程序中没有包含“#include <stdio.h>“这条预处理指令,当编译链接时,就无法确定printf函数名的来历,无法识别。
●源代码中屏蔽预处理指令://#include <stdio.h>。
●按F7编译时,底栏输出窗口会有如下错误提示:
1>------ 已启动生成: 项目: MyProjectOne, 配置: Debug Win32 ------
1>OneDay.c
1>d:\code\asm_to_c\myprojectone\chapter1\helloworld\oneday.c(12): warning C4013: “printf”未定义;假设外部返回 int
1>OneDay.obj : error LNK2019: 无法解析的外部符号 _printf,该符号在函数 _main 中被引用
1>D:\code\asm_to_c\MyProjectOne\Debug\MyProjectOne.exe : fatal error LNK1120: 1 个无法解析的外部命令
1>已完成生成项目“MyProjectOne.vcxproj”的操作 - 失败。
========== 生成: 成功 0 个,失败 1 个,最新 0 个,跳过 0 个 ==========
结论
编译时,输出窗口提示警告信息:oneday.c(12): warning C4013: “printf”未定义;
表示源代码第12行存在可疑问题,警告代码C4013。
链接时,输出窗口提示错误信息:OneDay.obj : error LNK2019: 无法解析的外部符号 _printf,该符号在函数 _main 中被引用。
表示链接时无法解析外部符号(函数名_printf),此函数名在main函数中被引用。
因为printf函数为外部库函数,如果屏蔽头文件<stdio.h>,缺少printf函数的声明,且找不到printf函数的定义,导致编译器无法解析外部符号函数名_printf,“printf”未定义。
【注】编译后的C语言函数名以下划线开头,如“_printf”、“_main”。
■函数头
int main(void)//函数头
头文件下方的下方是main函数,main函数是整个程序的入口函数。我们可以把函数分为函数头和函数体两个部分。我们首先看函数头。
●int表示函数的返回值类型为int类型(32位有符号整数,参见《X86汇编语言基础教程》预备知识部分)。C语言的函数名前为返回值的数据类型,如果函数没有返回值,则类型为“void”类型,表示为空的意思。
●main为函数名。函数名的真实含义是函数定义的起始地址标号,用符号表示函数的地址。main函数名是C语言控制台程序固定的入口函数名,不可以使用其他函数名。
实验二:修改main函数名
如果源程序中没有包含main函数,则链接器因无法解析_main外部符号导致错误。
●源代码中将main函数名修改为hello:int hello(void)//函数头。
●按F7编译时,底栏输出窗口会有如下错误提示:
1>------ 已启动生成: 项目: MyProjectOne, 配置: Debug Win32 ------
1>OneDay.c
1>MSVCRTD.lib(exe_main.obj) : error LNK2019: 无法解析的外部符号 _main,该符号在函数 "int __cdecl invoke_main(void)" (?invoke_main@@YAHXZ) 中被引用
1>D:\code\asm_to_c\MyProjectOne\Debug\MyProjectOne.exe : fatal error LNK1120: 1 个无法解析的外部命令
1>已完成生成项目“MyProjectOne.vcxproj”的操作 - 失败。
========== 生成: 成功 0 个,失败 1 个,最新 0 个,跳过 0 个 ==========
结论
实验二中,链接时出现错误提示:MSVCRTD.lib(exe_main.obj) : error LNK2019: 无法解析的外部符号 _main,该符号在函数 "int __cdecl invoke_main(void)" (?invoke_main@@YAHXZ) 中被引用。
虽然已经将main函数名修改为hello,但是编译时错误提示信息明确的告知无法解析外部符号_main,即未找到_main函数名。
"int __cdecl invoke_main(void)"表示在调用main函数时需要引用“_main”。
因此,在C语言程序中至少应该包含一个程序入口函数,main函数不可缺省。
●main函数名后圆括号内为函数参数列表,示例一中的main函数参数为“void”,void是空的意思,表示没有参数,void也可以不写:int main()//函数头。
我们可以在微软MSDN网站查阅“main函数和命令行参数”,以下是MSDN网站提供的说明:
*******************************************************************************
main 函数没有声明,因为它内置于语言中。如果有,则 main 的声明语法如下所示:
int main();
int main(int argc, char *argv[]);
函数的原型声明同样以分号结束。如果 main函数中未指定返回值,编译器会提供零作为返回值。注:VS编译器默认缺省整数数据类型为int类型,浮点数据类型为double。
标准命令行参数
main 函数参数可以方便进行命令行分析。 argc 和 argv 的类型由编程语言定义。名称 argc 和 argv 是传统名称,也可以按自己的意愿命名。
自变量定义如下所示:
argc
包含 argv 后面的参数计数的整数,表示参数的个数。 argc 参数始终大于或等于 1。
argv
表示由用户输入的命令行参数,以 null 结尾的字符串数组,即命令行参数数组。按照约定,argv[0] 是用于调用程序的命令。 argv[1] 是第一个命令行参数。 命令行的最后一个参数是 argv[argc - 1],并且 argv[argc] 始终为 NULL。
*******************************************************************************
总结
C89/C99/C11标准文档中,main函数有两种形式的函数声明:
1.int main();
这是示例一采用的方式。是否还记得,我们在创建项目时,选择的是Windws控制台程序。意味着项目程序采用Windows控制台窗口的形式运行,而不是命令行的方式运行。因此,main函数中不需要命令行参数。
2.int main(int argc, char *argv[]);
第二种函数声明,表示程序采用命令行方式运行。程序运行时操作系统需要先获取命令行参数,然后才可以运行。因此main函数需要带有命令行参数。Unix和Linux操作系统以命令行方式执行程序时,需要采用第二种main函数声明方式。
3.int main(int argc,char *argv[],char *envp[]);
这是main函数的第三种函数声明方式。这种方式比第二种方式多了一个参数envp[],用于获取环境变量,这种形式源于编译器的扩展。全局变量environ可以代替参数envp[]的作用,获取或者设置环境变量可以使用getenv或putenv,因此也没有必要使用该形式,不建议使用。
提示
本书所有示例都使用C语言Windows控制台程序(创建项目时选择控制台应用程序),而在《Windows API每日一练》一书中创建的都是C语言Windows窗口程序(创建项目时选择桌面应用程序)。
所谓的控制台窗口,其实是DOS系统文本显示模式的延伸,我们所看到的Windows控制台输出窗口其实就是显存的一部分,可以视为由行和列构成的二维数组。将要输出的内容写入到该显存就可以在控制台窗口显示出来了。关于Windows控制台窗口的设置可以参阅《X86汇编语言教程》第三部分第四十章Win32控制台程序。
■函数体
{
printf("Hello World!");//控制台窗口打印字符串
return 0;//结束返回
}
函数头下方左右大括号部分的内容就是函数体了。C语言函数以大括号的形式定义语句块。左大括号表示函数开始的位置,右大括号表示函数结束。大括号中间的内容就是语句块。如果语句块内只有一行语句,可以省略大括号(对于初学者而言,不建议省略大括号)。如果语句块内包含多行语句,则不可以省略大括号。
示例一函数体中包含两条语句。
printf("Hello World!");
功能为调用printf函数在控制台窗口打印字符串"Hello World!"。printf为函数名,是C语言标准库中的控制台输出函数,圆括号内的实参是一个常量字符串,”;”表示语句结束。
return 0;//结束返回
表示函数结束,将控制权交还给操作系统,函数返回值为0。示例一OneDay.exe程序的执行过程为:Windows操作系统创建进程OneDay.exe,并从main入口函数开始执行。当main函数执行完毕,退出进程时,将返回值0返回给Windwos操作系统,结束进程。
实验三:屏蔽return 0;
如果源程序中屏蔽return 0;语句,程序仍然可以正常编译运行。
●源代码中屏蔽return 0;语句://return 0;//结束返回。
●按Ctrl+F5,程序正常编译运行。
结论
main函数中可以缺省return 0;语句。如果 main 函数中未指定返回值,编译器会提供零作为返回值。右大括号表示函数结束返回。
注意
1.与汇编语言不同,C语言区分大小写,编写源代码时需要特别注意。
2.C语言字符串常量又称为字符串字面量,用双引号,例如"Hello World!"。如果是单个字符,则使用单引号,例如’A’。
3.C语言语句中使用缩进表示上下文从属关系,这一点在后面的示例代码中会做说明。通常大括号内的语句使用缩进增强代码可读性。
4.C语言中,语句使用”;”结束,而代码块使用右大括号结束,左大括号表示语句块开始。
1.3.4 汇编解析
接下来我们把示例一的C语言代码翻译为汇编语言,然后再使用VS的反汇编功能,对比分析手工翻译的汇编代码与VS反汇编的代码有何区别,以此来理解C语言代码的实现和执行过程。
■汇编代码
;FileName:OneDay.asm
;例1:示例代码1-第一个程序HelloWorld的汇编代码
;by:bcdaren
;2023.08.15
;===============================
;C标准库头文件和导入库
include vcIO.inc
.data
szMsg db "Hello World!",0
.code
start:
invoke printf,offset szMsg;控制台窗口打印字符串
invoke _getch ;等待输入单个字符
ret ;结束返回
end start
●vcIO.inc头文件
;// vc15/17/19IO.inc declarations for standard I/O ,
;// console I/O Function prototypes
;// Copyright (c) FCL1990~2018. All rights reserved.
;// 2023-08-15
.686 ;支持.686及以上指令集
.MODEL flat, stdcall ;flat平台内存模式,stdcall调用约定
option casemap : none ;区分大小写
;//========================================
includelib msvcrt.lib ;C运行时库(简称CRT)
;//========================================
;// Function prototypes
printf PROTO C : dword,:vararg ;vararg参数:可变参数,参数个数不确定。
scanf PROTO C : dword,:vararg ;dword参数 :格式字符串的有效地址。
_getch PROTO C : vararg ;【注意】所有的参数都是dword型!
_kbhit PROTO C : vararg
puts PROTO C : dword,:vararg ;vararg参数:可变参数,参数个数不确定。
gets PROTO C : dword,:vararg
本书的汇编开发环境采用masm32工具包。但是masm32开发环境只支持Windows API函数头文件,并不包含C语言标准库函数头文件stdio.h,因此我们自定义一个C语言标准库输入输出函数的函数声明头文件vcIO.inc。
vcIO.inc头文件中的“includelib msvcrt.lib”语句包含C运行时.lib文件,编译时链接“本机CRT启动的静态库。
接下来,vcIO.inc头文件中声明了6个C语言标准库中的输入输出函数,分别是printf、scanf、_getch、_kbhit、puts和gets。
printf函数:将格式化后的字符串输出到标准输出设备(控制台窗口)。
scanf函数:按用户指定的格式从键盘上把数据输入到指定的变量之中。
_getch函数:从stdio输入流中读取字符,即从键盘读取一个字符,但不显示在屏幕上。
_kbhit函数:检查当前是否有键盘输入,若有则返回一个非0值,否则返回0。
puts函数:向标准输出设备屏幕(控制台窗口)输出字符串并换行。
gets函数:从标准输入设备stdin(键盘)读取一行,并把它存储到 str 所指向的字符串中。
提示
1.汇编语言头文件后缀名为.inc。
2.“PROTO” 为函数声明伪指令,“C”为调用约定。
3.如果需要使用其他C语言标准库函数,可以在vcIO.inc头文件中添加函数原型声明。
4.本书仅支持32位ANSI C标准库函数。
5.C语言标准库中的函数名以下划线开头,以堆栈方式传递入口参数,返回值存放在eax寄存器中。
6.C语言输入输出分为stdin标准输入流、stdout标准输出流和stderr错误流,默认缓冲区大小为512个字节。
标准输入流stdio
任何一个应用程序都有可能要和一个文件或者设备进行交互,如果程序需要读取设备信息,那么就是通过stdin标准输入流来读取。标准输入流默认是键盘。
标准输出流stdout
stdout其实是一个文件,该文件可以将输出流输出到指定的设备中,比如网卡、打印机。
标准输出流不做任何操作时,默认是显示器的终端。(默认向显示器打印数据)。
标准错误流stderr
stderr无缓冲实时输出错误信息。例如fprintf(stderr, "错误号: %d\n", errno); errno代表的是错误的编号,不同的编号对应不同的错误信息,用于对当前程序中出现的错误进行反馈。可以在errno.h头文件中查阅错误定义。
#define ECHILD 10
#define EAGAIN 11
#define ENOMEM 12
#define EACCES 13
#define EFAULT 14
#define EBUSY 16
#define EEXIST 17
#define EXDEV 18
#define ENODEV 19
#define ENOTDIR 20
…
7.关于masm32编译环境参见《X86汇编语言基础教程》第二十八章32位汇编学习环境。
●makefile文件
# makefile OneDay.asm
NAME = OneDay
OBJS = $(NAME).obj
ML_FLAG = -c -coff
LINK_FLAG = /subsystem:Console
$(NAME).exe: $(OBJS)
Link $(LINK_FLAG) $(OBJS)
.asm.obj:
ml $(ML_FLAG) $<
clean:
del *.obj
编译选项为“-c -coff” :只编译,生成符合公共目标文件格式的目标文件。
链接选项为“/subsystem:Console”生成控制台程序。
●编译
第一步:Win+R键打开管理员命令行提示符,运行cmd.exe。将默认路径C:\Users\16400>切换为当前编译目录D:\code\asm_to_c\MyProjectOne\chapter1\helloworld>。
第二步:输入nmake /a命令,显示以下内容:
Microsoft (R) 程序维护实用工具 14.16.27045.0 版
版权所有 (C) Microsoft Corporation。 保留所有权利。
ml -c -coff OneDay.asm
Microsoft (R) Macro Assembler Version 6.14.8444
Copyright (C) Microsoft Corp 1981-1997. All rights reserved.
Assembling: OneDay.asm
Link /subsystem:Console OneDay.obj
Microsoft (R) Incremental Linker Version 14.16.27045.0
Copyright (C) Microsoft Corporation. All rights reserved.
此时,已完成编译链接,并生成OneDay.exe程序。如果编译链接出现错误,会显示相应的提示信息。
●运行结果(略)
●代码分析
汇编源代码可以分为3个部分:
第一部分:include vcIO.inc,将vcIO.inc头文件复制到include地址处
第二部分:数据定义
.data
szMsg db "Hello World!",0
在.data全局数据段中,szMsg地址处已db(字节)为单位定义了一个零结尾的字符串"Hello World!"。
第三部分:代码
.code
start:
invoke printf,offset szMsg;控制台窗口打印字符串
invoke _getch ;等待输入单个字符
ret ;结束返回
end start
在.code代码段中,编写汇编指令代码。end start表明程序的入口地址为start地址处,相当于C语言main函数的地址。
第一个invoke是高级汇编语句,调用C标准库中的printf函数,输出szMsg地址处的字符串,offset szMsg是printf函数的参数,为字符串在数据段内的偏移地址。这条汇编语句等价于C语句:
printf("Hello World!");
第二个invoke语句调用C标准库中的另一个函数getch,接收键盘单个字符的输入。按任意键结束程序。调用这个函数是为了方便观察printf函数输出的结果。这条汇编语句等价于C语句:
getchar();
最后一个ret指令结束main函数,并返回。等价语言C语句:return 0;
■反汇编代码
在VS源代码编辑窗口鼠标选中main函数,按下F9下断点,或者直接鼠标点击该行最左侧边框下断点,如图1-26所示。
图1-26 VS下断点
按下F5,开始调试。以下是反汇编窗口输出的反汇编内容。
注:反汇编窗口的打开方式,点击VS“调试”>“窗口”>“反汇编”。
--- d:\code\asm_to_c\myprojectone\chapter1\helloworld\oneday.c -----------------
/*FileName:OneDay.c
;例1:示例代码1-第一个程序HelloWorld
;by:bcdaren
;2023.08.15
;===============================
/*
显示Hello World!
*/
#include <stdio.h>//C标准库输入输出头文件
int main(void)//函数头
{
1.建立堆栈框架
00D51810 push ebp
00D51811 mov ebp,esp
00D51813 sub esp,0C0h
2.保护寄存器
00D51819 push ebx
00D5181A push esi
00D5181B push edi
3.初始化函数堆栈空间
00D5181C lea edi,[ebp-0C0h]
00D51822 mov ecx,30h
00D51827 mov eax,0CCCCCCCCh
00D5182C rep stos dword ptr es:[edi]
00D5182E mov ecx,offset _F2A4B1DC_oneday@c (0D5C003h)
00D51833 call @__CheckForDebuggerJustMyCode@4 (0D51212h)
4.printf("Hello World!");//控制台窗口打印字符串
00D51838 push offset string "Hello World!" (0D57B30h)
00D5183D call _printf (0D5104Bh)
00D51842 add esp,4
5.return 0;//结束返回
00D51845 xor eax,eax //返回值0
}
6.恢复寄存器
00D51847 pop edi
00D51848 pop esi
00D51849 pop ebx
7.释放堆栈框架
00D5184A add esp,0C0h
00D51850 cmp ebp,esp
00D51852 call __RTC_CheckEsp (0D5121Ch) //校验堆栈
00D51857 mov esp,ebp
00D51859 pop ebp
00D5185A ret
上述内容是VS编译器将C语言代码翻译成汇编后的代码,称为反汇编代码。我们可以把上述的反汇编代码分为7个部分,其中对应C语言函数体的部分为第4部分。
●反汇编与汇编代码比较
1.汇编指令
反汇编代码中的每一条指令对应一条机器指令,体现了代码的整个执行过程。
汇编代码采用高级汇编伪指令,一条invoke伪指令可以分解为多条汇编指令。例如:
invoke printf,offset szMsg;控制台窗口打印字符串
等价于
00D51838 push offset string "Hello World!" (0D57B30h)
00D5183D call _printf (0D5104Bh)
2.反汇编代码包含完整的堆栈框架,而汇编代码采用段的简化定义形式,省略了堆栈框架和寄存器保护,由编译器在编译时自动添加堆栈框架和寄存器保护。
3.反汇编代码仅包含代码段的内容,而汇编代码包含完整的源代码,包括头文件、数据段和代码段的定义。
4、反汇编代码中包含了编译器的优化代码,例如反汇编代码的第3部分初始化堆栈空间和第7部分释放堆栈框架时的堆栈校验以及恢复堆栈平衡。
●反汇编与C语言比较
我们把C语言称为高级语言,意思为更接近人类的语言,编写C语言源代码的效率更高,更为简洁。我们可以将C语言称为高级汇编语言,是高级汇编的进一步简化。而C语言的实现都是建立在功能更为强大的编译器的基础上的。C语言由编译器将其翻译成汇编语言,然后再通过汇编器将其翻译为二进制可以执行程序。因此,可以将C语言视为在汇编语言基础上实现的。反汇编代码就是VS编译器将C语言翻译为汇编语言的代码。
C++、JAVA、Python等其他高级语言又是建立在C语言的基础上的。在计算机领域,几乎所有的基础软件都是由C语言实现的,C语言是各种不同计算机平台的通用语言。因此,在C语言基础上创建的其他高级语言当然可以实现跨平台的通用性。
因此,学习C语言是学习其他高级语言的有效途径,而本书从汇编语言的角度学习C语言,则更透彻的理解C语言的实现过程和本质。对于渴望追求真理的读者,建议先学习《X86汇编语言基础教程》,然后再学习C语言,这样对C语言的理解更为深刻,学习的效率也更高。
练习
- 在控制台窗口中实现“Hello World!”,熟记“Hello World!”程序代码。
- 自己编写程序在控制台窗口中显示“我的第一个程序,Hello World!诞生了”。
本文摘自编程达人系列教材《汇编的角度——C语言》。