一道 C 语言指针访存题目的引申

2009/11/08 | 13:41 | 分类:计算机科学与编程 | 标签: | 4,611次阅读

  毕业生求职的时节,非毕业生接触到各种面试、笔试题目的几率也会相应地增加。下面请看一道经典的 C 语言指针访存题目,稍有些经验的朋友应该很快可以看出这个题目考查的是字节序、内存布局等知识点。然后在大脑中略排列一下,就能够给出答案(2000000)。

  1. #include <stdio.h>
  2.  
  3. int main()
  4. {
  5.     int a[5] = {1, 2, 3, 4, 5};
  6.     int *pa = (int)(&a) + 1;
  7.     printf("%x\n", *pa);
  8.     return 0;
  9. }

  不过,这个答案是否绝对正确,还要看题目所处的上下文了。如果题目明确说是在常见的 32 位 x86 平台上运行,那就无可厚非;但如果没有指明机器架构,那就要小心一点了,也许命题者真想考查一下求职者对非 x86 平台的了解程度呢。如果考虑机器架构,这个题目应当如何作答呢?粗想一下,我们需要考虑的是字长、字节序和对齐(alignment)访问规则。不过真要做实验看看,会发现这里面还是有一些花样的。如果没有实际经验,只凭教条加推测,很可能想不到其它平台上的一些细节之处。
  我们换用一段信息量更丰富的程序来进行后续的实验。在不同的平台上,均使用未加特殊参数的 gcc 来编译这段程序——

  1. #include <stdio.h>
  2.  
  3. int main()
  4. {
  5.     int x;
  6.     int a[5] = {0x11121314, 0x21222324, 0x31323334, 0x41424344, 0x51525354};
  7.     for (x = 0; x < 20; x++) {
  8.         printf("%02x ", *(char *)((int)(&a) + x));
  9.     }
  10.     printf("\n");
  11.     for (x = 0; x < 8; x++) {
  12.         printf("%08x ", *(int *)((int)(&a) + x));
  13.     }
  14.     printf("\n");
  15.     return 0;
  16. }

  在 32 位 x86 下的结果不需要多解释。

  1. uname -a
  2. Linux ubuntu 2.6.31-14-generic #48-Ubuntu SMP Fri Oct 16 14:04:26 UTC 2009 i686 GNU/Linux
  3. ./a.out
  4. 14 13 12 11 24 23 22 21 34 33 32 31 44 43 42 41 54 53 52 51
  5. 11121314 24111213 23241112 22232411 21222324 34212223 33342122 32333421

  而在 64 位的 x86_64 下,由于 8 字节的指针被截断到了 4 字节的整型长度,故会引发段错误。同样的情况出现在 64 位的 Alpha 机器下。解决办法自然是把运算地址时的 int 修改成 long 或某种显式的 64 位类型。修改后的结果应该与 32 位 x86 一致。

  1. uname -a
  2. Linux ubuntu 2.6.24-22-generic #1 SMP Mon Nov 24 19:35:06 UTC 2008 x86_64 GNU/Linux
  3. ./a.out
  4. Segmentation fault
  1. uname -a
  2. NetBSD sdf 2.1.0_STABLE NetBSD 2.1.0_STABLE (sdf) #0: Fri Mar 30 02:24:32 UTC 2007  root@ol:/var/sys/arch/alpha/compile/sdf alpha
  3. ./a.out
  4. Memory fault (core dumped)

  有趣的是在 XScale(Intel 实现的 ARMv5)下,虽然同属 little-endian,但非对齐取数时出现了在字内按字节循环的移位的结果。查查 ARM 的官方文档,这确实是 ARMv5 的特性;而在 ARMv6 以后,非对齐访问则是完全支持的。

  1. uname -a
  2. Linux zaurus 2.4.18-rmk7-pxa3-embedix #1 Sat, 06 Aug 2005 12:22:55 +0000 armv5tel unknown
  3. ./a.out
  4. 14 13 12 11 24 23 22 21 34 33 32 31 44 43 42 41 54 53 52 51
  5. 11121314 14111213 13141112 12131411 21222324 24212223 23242122 22232421

  接下来看看 PowerPC,它是 big-endian 的代表,允许 32 位以内的非对齐访问,结果是容易理解的。有关 PowerPC 非对齐访问的一些细节可以参考这篇文章

  1. uname -a
  2. AIX aix 3 5 00C97AC04C00 powerpc unknown AIX
  3. ./a.out
  4. 11 12 13 14 21 22 23 24 31 32 33 34 41 42 43 44 51 52 53 54
  5. 11121314 12131421 13142122 14212223 21222324 22232431 23243132 24313233

  同样是 big-endium 的 SPARC 则不允许非对齐访问。它会对非对齐访问抛出 SIGBUS。

  1. uname -a
  2. SunOS t1000 5.10 Generic_118833-33 sun4v sparc SUNW,Sun-Fire-T1000 Solaris
  3. ./a.out
  4. 11 12 13 14 21 22 23 24 31 32 33 34 41 42 43 44 51 52 53 54
  5. Bus Error (core dumped)

  最后看看我们中科院计算所的龙芯(Loongson)2E,它是兼容 MIPS 架构的处理器。很多教科书告诉我们说通常的 MIPS 是不允许非对齐访问的(部分 MIPS 实现提供了非对齐访问指令,并申请了专利),但我们在龙芯下却得到了和 x86 相同的、允许非对齐访问的结果,这又是为什么呢?初步查到的原因是“(针对龙芯修改过的 Linux)内核里确实有一个异常处理函数负责处理 lw 访问非对齐地址引起的异常”。这也许是龙芯绕开 MIPS 专利的一种办法?我会向龙芯团队的同学求证一下,也希望熟悉 MIPS 或龙芯的朋友给我一个确切的答案。

  1. uname -a
  2. Linux Loongson-1 2.6.18.1lemote #1 Sat Jan 13 16:02:26 CST 2007 mips GNU/Linux
  3. ./a.out
  4. 14 13 12 11 24 23 22 21 34 33 32 31 44 43 42 41 54 53 52 51
  5. 11121314 24111213 23241112 22232411 21222324 34212223 33342122 32333421

  不过用心思考的朋友也许会发现上面一系列实验存在的一个疏漏:没有考虑编译器的影响。一方面,编译器可能对整型的字长有不同的规定(例如 Windows 下的某些编译器即使在 32 位 x86 上也会把 int 定义为 16 位);另一方面,编译器可以对不支持非对齐访问的处理器生成一定的指令序列、通过多次访存来模拟非对齐访问。我们看下面的例子:还是在 SPARC 平台上,改用 Solaris 自带的 Sun CC 来编译实验程序,这时就不会出现“Bus Error”,而会输出和 PowerPC 一样的结果。因为 SunCC 默认会使用“-xmemalign”参数来生成适当的访存指令序列。

  1. uname -a
  2. SunOS t1000 5.10 Generic_118833-33 sun4v sparc SUNW,Sun-Fire-T1000 Solaris
  3. cc data.c
  4. ./a.out
  5. 11 12 13 14 21 22 23 24 31 32 33 34 41 42 43 44 51 52 53 54
  6. 11121314 12131421 13142122 14212223 21222324 22232431 23243132 24313233

  这样看来,在不指定机器架构和编译器等上下文的情况下,要正确且完美地回答一开始的那道题目还是需要一定知识积累的。答案省略,留给大家自己求解。在面试、笔试诸如 Sun SPARC、IBM PowerPC、中科院计算所微处理器中心等部门或者做 ARM 等嵌入式开发的公司时,最好先了解清楚它们的产品常识。
  (部分实验环境来源于 Unix-Center.Net,在此致谢)

C和C++处理register关键字的一处差异

2009/04/19 | 23:08 | 分类:计算机科学与编程 | 标签: | 10,406次阅读

  C++并不是完全兼容C语言的,上次提到的sizeof('a')等于几的问题就是一例。今天我在编码时又无意中发现了一处不同:
  用register关键字修饰的变量,在C语言中是不可以用&操作符取地址的,这是我已有的经验。因为编译器如果接受了程序员的建议把变量存入寄存器,它是不存在虚拟地址的。但在C++中,用register修饰的变量可以用&操作符取地址,这是我在一段代码中发现的。如果程序中显式取了register变量的地址,编译器一定会将这个变量定义在内存中,而不会定义为寄存器变量。
  我在C99(ISO/IEC 9899:1999)和ISO C++(ISO/IEC 14882:2003)标准中得到了确认,C和C++标准对register遇到&的处理确实有不同的明确定义。但为什么要这样定义?我只能从标准的字里行间猜测。K&R C1中如何描述register我尚未查证,K&R C2(ANSI C)中说明了“register variables are to be placed in machine registers ... but compilers are free to ignore the advice ”。但在C99和ISO C++中,措辞分别变成:“suggests that access to the object be as fast as possible”、“a hint to the implementation that the object so declared will be heavily used”,不再特别提及“machine registers”。可见历史上register关键字在强调尽可能地把变量保存到寄存器,而现在的register关键字不再强调具体手段,只是建议编译器通过各种可行的方式优化该变量的访问(不过很多编译器会忽略这一关键字,而采用自身的优化策略)。C99可能是为了保持对K&R C的兼容而不允许取地址操作;而C++也许是因为没有历史包袱才放宽了这个限制吧。猜测而已,希望知道内幕的朋友告诉我更精确的答案。

编译器与标准——严格还是智能?

2009/03/21 | 14:21 | 分类:计算机科学与编程 | 标签: | 4,055次阅读

  最近做的C/C++工程需要在多种平台下编译(下面所说的编译泛指编译、连接等过程)并运行通过,多种不同版本的编译器考验着代码正确性和严格性。经测试,我们的历史代码中存在着这样几类问题,使得它们在VS2008和低版本gcc下编译通过,但在高版本的gcc下编译就会或多或少地报错:
  1、使用了外部的函数,但没有在本文件中声明或#include对应的头文件。VS2008和gcc3.2在很多情况下可以智能地从一同编译的其它文件或标准库中找到对应的函数并连接,而gcc4.3要求必须声明。
  2、使用模板时,T::name表示T::name类型还是T类的name成员?即使不使用typename关键字,VS2008和gcc3.2在很多情况下可以智能推断T::name a;中的T::name表示表示一个类型。但在gcc4.1/4.3下必须使用typename T::name来表示T::name类型。
  3、隐式类型转换。对于函数Func(string&),在VS2008中可以采用Func(string("new"));来调用,但gcc不能隐式地转换构造函数的返回,需要使用string str("new"); Func(str);来调用。
  4、一些copy-paste粗心小问题,比如声明类的成员函数时不应该加“类名::”限定。如果加了,VS2008会忽略,而gcc4.1/4.3会报错。
  5、非标准的库函数,比如VS2008的itoa(_itoa)。如果说上述几条是“严格性”的问题,这个应该算是“正确性”的问题了———个编译器扩展的内容在另一个编译器看来自然是不知所云。当然,为方便起见,使用编译器提供的非标准扩展也是可以的(比如在不完全支持C99的gcc和VC版本下,分别使用atoq和_atoi64来代替C99的atoll),但这时候别忘了使用条件编译,针对不同的编译器提供不同的代码。
  等等。
  总体来看,我们用到的几个编译器的严格程度如下。注意仅仅是从一些小的侧面反映的结果,不完全准确,说明大致情况而已。

  ─────── 更严格 ──────→
   VS2008  gcc3.2  gcc4.1  gcc4.3
  ←────── 更智能 ───────

  编译器的严格性也许是一把双刃剑。但我个人认为严格一些还是利大于弊的。拿上面的问题1来说,我就遇到过gcc3.2“智能”连接错误的问题,而如果使用gcc4.3,这个bug就可以轻易地被定位,节约大量的调试时间。
  智能的底线是正确性。如果代码中出现了不规范的写法,编译器给出智能判断并忽略问题的同时,应该给程序员一定的警告信息,让他们核实代码并判断是否需要向标准的方向修改。不过可悲的是编译器的警告往往被程序员们一略而过了。也许正是因为这一点,gcc才变得越发严格,试图和程序员划清责任域,减少扯皮现象?
  当然,仅仅写出语法上严格的代码,解决了不同编译器下编译通过的问题,我们还不能保证程序运行时语义的正确性。例如程序中存在未初始化的变量,一种编译器的实现是以0填充,恰好与程序员的原意相符;而切换到另一编译器下,未初始化的变量成了随机值,程序运行就会出问题。所以,对C/C++这样高度自由的语言来说,严格的编译器也不是万能的,人的因素始终是第一位的。
  说了这么些严格和智能,评判它们的依据是什么?自然是语言的标准了(C99、ISO C++之类)。有关编译器对语言(包含库)标准的支持,我们大约可以按以下维度划分:

      标准要求实现的
         ↑
         │
  标准未定义的 │ 标准定义的
     ←───┼───→
         │
         │
         ↓
      编译器扩展的

  标准定义的语法在任何编译器下都应该有相同、无歧义的语义。尽管其内部实现可能不同,或是依赖于具体的机器架构,但在标准中总有明确的说明。而标准中也存在大量未定义的语义,这通常是为了让编译器作者放开限制,做更高效、更简单的实现。这类语法在不同的编译器或不同的运行环境下可能产生不同的结果,经典的例子就是n=m+++m++这种副作用语句的叠加,再如L'abcd'表示什么的问题。
  工程中通常应该严格禁用标准未定义的语法,因为机器架构、编译器种类和版本、优化选项、运行时环境等的变化都有可能对程序的运行结果产生不可预知的影响。但有些时候标准未定义的的语法也是有用的,比如为了学习理解编译器的原理和优化算法。另外在一些技巧性游戏,如Quine,以及IOCCCTime Limit Exceeded这样的hack竞赛中,使用未定义语法的捷径也是未尝不可的。
  而编译器扩展的语法和库则是一类相对有用的工具。不同于标准未定义的内容,编译器所做的扩展也是有严格定义的,不会产生不可预知的结果。在对代码的迁移性要求不高的场合直接使用并无大碍。如果对代码的迁移性有要求,那就如上文所述使用条件编译来限制其作用范围。毕竟编译器所做的扩展是为了弥补标准库的不足,方便开发者。如果对代码将来的使用范围有明显的预知,就需要权衡是否使用编译器的扩展特性。不过就连Linux内核的代码都在使用gcc的扩展语法
  在C/C++语言教学中,强调标准是十分必要的。然而市面上的C/C++教材鱼目混珠:那天在师兄的桌上看到的一本国内某知名高校出版的C++教材上,Hello World程序竟有3处明显的问题,不用试验就知道在VS2008或gcc下编译有两处是Error,一处至少是Warning(据说在VC6下可以编译通过)。一些高校计算机教学中普遍使用历史遗留的、非标准的编译器也成为滋生不良编程习惯、阻碍标准推广的源泉。究其原因,某些教师的惰性是一方面,而相关资格考试与现行标准、业界规范的严重脱节也成为应试教育体制下技术标准难以为师生重视的重要因素。正面的例子又如何呢?北理工计算机学院有一门程序设计实践与方法的本科课程,这门课的重心本是算法与编程技巧的训练,但由于它使用了基于gcc的Online Judge机制和像ACM-ICPC一样严格、众多的测试用例,使得学生对代码的标准与严格、算法的正确与高效、条件的完全覆盖必须做仔细的考量。我自己上过这门课,也做过这门课的助教,两种角色的体验让我深知师生各自的抱怨与苦衷。错误究竟在谁?我想不在于这门课的教师和学生,而在于这门课之外的计算机教育氛围。改变这个氛围并不可以一蹴而就,但至少有两点是可以尽己所能的:主观方面,建立标准意识,培养良好的编程习惯;客观方面,使用实现了符合现行标准的编译器。尽管这对计算机教学的整体环境影响甚微,但起码对未来从事计算机方面的工作来说是有益的——实际工程对标准的要求非同儿戏。

sizeof的一些牛角尖问题

2009/02/15 | 16:08 | 分类:计算机科学与编程 | 标签: | 6,148次阅读

  昨天看了一些C语言问题(http://www.gowrikumar.com/c/)以及国内几个Geeks的解答(http://wangcong.org/blog/?p=291http://www.matrix67.com/blog/archives/429),看来自己的C基础还是可以的,没遇到什么大问题。有时候自己也去分析过IOCCC(http://www.ioccc.org/)的代码,或多或少可以学到一些东西。尽管听起来都像是“‘回’字有四样写法”的那样。
  说几条我最近想到的问题及其解答,都是关于sizeof的:
  1、sizeof(i++)之后,i的值会怎样?答案是不变。记得大一初学C语言时想研究一下sizeof与函数有什么区别,得到的结果只是一些语法上的差别;学了汇编之后看看编译器生成的代码,才发现sizeof在编译时直接给定了一个常值,而非在运行时求值。进而又分析过sizeof(表达式)的结果,清楚了类型提升原理。但我之前没有注意过表达式中出现副作用的问题,于是在sizeof(i++)的问题上犹豫了。现在经过查阅资料和实验,结论是:sizeof在大多数情况下是编译时定值的,表达式中的任何副作用(包括有副作用的运算符、函数调用等)都不会发生。这里说“大多数情况”,排除了针对C99的新特性——不定长数组(variable length array)的特例。参考这篇文章(http://rednaxelafx.javaeye.com/blog/225909),如果sizeof运算符的参数是一个不定长数组,则该需要在运行时计算数组长度。
  2、sizeof('a')的结果是多少?这个要看是在C中还是C++中了。根据C99标准的规定,'a'叫做整型字符常量(integer character constant),为int型,故结果是4(对于32位机器);而ISO C++规定,'a'叫做字符字面量(character literal),为char型,故结果是1。C强调了'a'的“数”属性,而C++强调了'a'的“字符”属性。
  3、sizeof('ab')的结果又是多少?'ab'这种语法我以前没有注意到。经查,这叫做“多字节字符常量”(multi-character character constant),它限制在单引号中包含2至4个字节。根据标准,多字节字符常量的语义由编译器的实现决定。在我测试的gcc 4.0和VS2008中,如果int a = 'abcd',则a == 0x61626364。sizeof('ab') == sizeof('abc') == sizeof('abcd') == 4。
  4、那么sizeof(L'a')呢?虽然wchar_t是在源代码级可移植的宽字符,但其大小依赖于操作系统或编译器的定义。无论C或C++,wchar_t字面量本身就是wchar_t类型的常量,所以sizeof(L'a')就等于sizeof(wchar_t)。在我在32位Windows和Linux平台下分别为2和4。
  5、至于sizeof(L'ab')、sizeof(L'中')、sizeof(L'中国')又会如何?宽字符常量的单引号中出现多个字节构成的单个字符(如L'中')是合法的,对它取sizeof,结果等于具体实现下的sizeof(wchar_t)。但出现多个字节构成的多个字符(如L'ab'、L'中国')则是没有定义的,编译器可能报错,也可能给出不同的实现。在我测试的gcc4.0和VS2008中,L'abcd'分别返回了0x64和0x61。对它们取sizeof,结果等于具体实现下的sizeof(wchar_t),但注意这是标准未定义的,不应该确信。
  这些东西在现实的工程中很少用到,毕竟自己写代码的时候都偏向于保守的、确保清晰而正确的方法。但在求职面试之类的场合,sizeof还是一个比较重要的考点,钻一钻牛角尖是值得的。

分清程序“所在路径”和“执行路径”

2006/09/14 | 21:49 | 分类:Windows开发 | 标签: | 2,958次阅读

  我的一个程序在执行时需要调用其所在路径下的数据文件。这个程序在独立运行时没有问题,但用第三方程序将其作为子进程加载后,却发生了无法找到数据文件的错误。其原因:我是通过相对路径查找数据文件的,在由第三方程序调用该程序时,执行路径是第三方程序所在路径,所以无法通过原相对路径找到数据文件。因此,我们有必要明确程序的“所在路径”和“执行路径”,才能正确地处理相对路径文件引用。

  下面给出C、C#和Python中,获取程序“所在路径”和“执行路径”的方法。其中的C程序只在Windows下有效,在Linux下缺乏现成的函数,需要“曲线”现实,可参见coldcrane的专栏《Linux下GetModuleFileName的四种写法》一文。

  1. #include <stdio.h>
  2. #include <windows.h>
  3.  
  4. #define MAXPATH 256
  5.  
  6. int main()
  7. {
  8.     char str[MAXPATH];
  9.  
  10.     GetModuleFileName(NULL, str, MAXPATH);
  11.     *(strrchr(str, '\\')) = '\0';
  12.     printf("The program is in: %s\n", str);
  13.  
  14.     getcwd(str, MAXPATH);
  15.     printf("The program runs at: %s\n", str);
  16.  
  17.     return 0;
  18. }
  1. using System;
  2.  
  3. namespace CSharp
  4. {
  5.     class Program
  6.     {
  7.         static void Main(string[] args)
  8.         {
  9.             string str;
  10.  
  11.             str = System.Diagnostics.Process.GetCurrentProcess().MainModule.FileName;
  12.             str = str.Substring(0, str.LastIndexOf("\\"));
  13.             Console.WriteLine("The program is in: " + str);
  14.  
  15.             str = System.IO.Directory.GetCurrentDirectory();
  16.             Console.WriteLine("The program runs at: " + str);
  17.         }
  18.     }
  19. }
  1. #!/usr/bin/python
  2.  
  3. import os
  4.  
  5. if __name__ == '__main__':
  6.     print "The program is in: %s" % os.path.dirname(__file__)
  7.     print "The program runs at: %s" % os.getcwd()

  附:Windows下简单测试的方法。在以上代码末尾添加诸如getchar()一类的暂停效果指令,编译之(当然Python就不用了),对生成的可执行文件建立快捷方式,修改快捷方式的属性,将“Start in(起始位置)”设为有别于程序所在路径的其它位置。运行这个快捷方式,观察“The program is in:”与“The program runs at:”的不同。