C语言参数求值顺序

TL;DR

“The order of evaluation of the function designator, the actual arguments, and subexpressions within the actual arguments is unspecified, but there is a sequence point before the actual call.” C99-6.5.2.2.10:

所有依赖“求值顺序”的函数调用都是未定义行为

案例 See Commit

故事开始于我想在一个老Thinkpad上跑一下之前写的Tiger编译器。 目的就是在不同的CPU架构上发现一些BUG,详见某次debug经历。然后不出意外的出意外了。

63
64
65
66
67
68
69
static Temp_temp nthTemp(Temp_tempList list, int i) {
  assert(list);
  if (i == 0)
    return list->head;
  else
    return nthTemp(list->tail, i - 1);
} // https://github.com/bajzc/tiger-in-c/blob/d2530a50ef6e0fa2eb50c4417f1421bd7cab7362/assem.c#L63

listNULL

这个函数的调用者是

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
typedef struct Temp_tempList_ {
  Temp_temp head;
  Temp_tempList tail;
} *Temp_tempList;

static void format(char *result, char *assem, Temp_tempList dst,
                   Temp_tempList src, AS_targets jumps, Temp_map m) {
    ...
    } else if (*p == '`')
      switch (*(++p)) {
        case 's': {
          int n = ATOI(++p);
          string s = Temp_look(m, nthTemp(src, n));
          STRCPY(result + i, s);
          i += STRLEN(s);
        } break;
    ...
// https://github.com/bajzc/tiger-in-c/blob/d2530a50ef6e0fa2eb50c4417f1421bd7cab7362/assem.c#L102

format函数的作用和printf类似,将输入字符串assem中的指示符 (d , s )替换成m表里的寄存器名字。

比如对于add `d0, `s0, `s1, format会返回add fp, fp, a0

问题出在当输入是add `d0, `s0, `s1的时候,src={some_ptr, NULL}

被这个表象误导了,认为可能是在某个地方(比如coloring的时候),不小心修改了src的内容, 我花了大概两个小时反复追踪srcassem的构造和修改的代码。

最后神奇地发现在codegen的地方,也就是构造assem的函数里

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#define L(h, t) Temp_TempList((Temp_temp) h, (Temp_tempList) t) // Constructor
#define S(format, ...) sprintf(buf, format, ##__VA_ARGS__)
static Temp_temp munchExp(T_exp e) {
  static char buf[80];
  Temp_temp r = Temp_newtemp();
  switch (e->kind) {
    case T_BINOP: {
      ...
      } else if ((e1->kind == T_CONST || e2->kind == T_CONST) &&
                 (op == T_plus || op == T_minus)) {
        /* BINOP(PLUS,CONST(i),e2) */
        /* BINOP(PLUS,e1,CONST(i)) */
        /* BINOP(MINUS,CONST(i),e2) */
        /* BINOP(MINUS,e1,CONST(i)) */
        T_exp var = e1->kind == T_CONST ? e2 : e1;
        int constt = e1->kind == T_CONST ? e1->u.CONST : e2->u.CONST;
        switch (op) {
          case T_plus: S("addi `d0, `s0, %d", constt); break;
          case T_minus: S("addi `d0, `s0, -%d", constt); break;
          default: assert(0);
        }
        emit(AS_Oper(STRDUP(buf), L(r, NULL), L(munchExp(var), NULL), NULL));
        return r;
      }
      ...
// https://github.com/bajzc/tiger-in-c/blob/02c247d4336ddc80234900628d5e9a1038a06e08/riscv_codegen.c#L228

当我break(很快就要考)到AS_Oper()里的时候,字符串竟然是以add `d0...开头的!

给我直接整的怀疑人生了,难道sprintf()的实现和我想的不一样,不是直接覆盖?难道是STRDUP()的实现有bug?

直到我想step(伏笔收回)进AS_Oper(),看看会不会是L()宏展开出了问题的时候: 我发现并没有进到strdup()里,而是开始调用munchExp()

也就是说编译器并没有像“我想的”一样从左往右,从里到外的求值,而是倒着来的。 好巧不巧,调用的正好是自己,而且在buf上的副作用也因为它是静态的被体现出来。 这就导致了传给AS_Oper()的汇编和其他信息不匹配。

问题

如果你觉得上面的问题细节太多,可以看看下面这个抽象出来的问题:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#include <stdio.h>
int eval2(int a, int b){
	printf("eval2 %d %d\n", a, b);
	return a + b + 1;
}
int eval(int a){
	printf("eval %d\n", a);
	return a + 1;
}
int main(){
	eval(eval2(eval(1), eval(eval(2))));
}

在Mac上Clang和GCC的运行结果一样:

1
2
3
4
5
eval 1
eval 2
eval 3
eval2 2 4
eval 7

也就是从左往右求值,但是在Thinkpad(gcc-x86-64-linux-gnu)上的结果是从右往左:

1
2
3
4
5
eval 2
eval 1
eval 3
eval2 2 4
eval 7

标准

C标准对求值顺序的规定是:没有规定

“The order of evaluation of the function designator, the actual arguments, and subexpressions within the actual arguments is unspecified, but there is a sequence point before the actual call.” C99-6.5.2.2.10:

既然如此,那为什么同样是GCC,在不同的架构上求值的顺序会不一样呢?

godbolt上探索汇编一下发现,Clang在两种架构上表现相同,而GCC则不同。

实现

为了知其所以然,直接上GCC代码! 注意到求值顺序的不同在Gimple(GCC的IR)中就已经体现了出来 那么我们就去看从AST转到Gimple的代码

4805
4806
4807
4808
4809
4810
4811
4812
4813
4814
4815
4816
4817
4818
4819
4820
4821
4822
4823
4824
4825
4826
4827
4828
4829
4830
4831
  // Gimplify the function arguments.  */
  if (nargs > 0)
    {
      for (i = (PUSH_ARGS_REVERSED ? nargs - 1 : 0);
	   PUSH_ARGS_REVERSED ? i >= 0 : i < nargs;
	   PUSH_ARGS_REVERSED ? i-- : i++)
	{
	  enum gimplify_status t;

	  /* Avoid gimplifying the second argument to va_start, which needs to
	     be the plain PARM_DECL.  */
	  if ((i != 1) || !builtin_va_start_p)
	    {
	      tree *arg_p = &CALL_EXPR_ARG (*expr_p, i);

	      if (gimplify_omp_ctxp && gimplify_omp_ctxp->code == OMP_DISPATCH)
		gimplify_omp_ctxp->in_call_args = true;
	      t = gimplify_arg (arg_p, pre_p, EXPR_LOCATION (*expr_p),
				!returns_twice);
	      if (gimplify_omp_ctxp && gimplify_omp_ctxp->code == OMP_DISPATCH)
		gimplify_omp_ctxp->in_call_args = false;

	      if t == GS_ERROR)
		ret = GS_ERROR;
	    }
	}
    ...

可以看到求值的顺序是正是反取决于PUSH_ARGS_REVERSED,它在每个架构的配置文件中被定义: 比如i386就是1, 而其他架构采用的就是默认值:0

让我们再进一步,为什么对于不同的架构需要指定不同的顺序呢? 参考2014年的讨论, 对于ARM来说,正序的压栈可以生成空间占用更小的二进制。

至于为什么会更小,就不知道了…

思考

很多时候自以为正确的代码是UB,这就是C语言! 比如

1
2
3
4
// copy array
i = 0;
while(i < n)
    y[i] = x[i++];

代码假设y[i]先被求值,但这其实是没有保证的!

类似的错误数不胜数,编译器只用编译就行了,而程序员要考虑的就多了(我写了UB难道编译器就没有责任了吗?

0%