假期调整

下周六是国庆节第一天, 给自己一个🕊的理由

  • 10月1日的直播调整到10月3日19:00~21:00
  • 10月8日的直播时间暂不调整

 

提前祝大家国庆节快乐!

引言

上次课内容:

  • 计算机系统(程序, 指令集, CPU)的状态机模型
  • 对 “程序如何在计算机上运行”建立基本认识

 

本次课内容:

C程序如何从源代码生成指令序列(二进制可执行文件)

  • 预处理 -> 编译 -> 汇编 -> 链接 -> 执行

Talk is cheap, show me the code!

预处理

预处理 = 文本粘贴

#include <stdio.h>
#define MSG "Hello \
World!\n"
int main() {
  printf(MSG /* "hi!\n" */);
#ifdef __riscv
  printf("Hello RISC-V!\n");
#endif
  return 0;
}
gcc -E a.c

头文件是如何找到的?

方法: 阅读工具的日志(查看是否支持verbose, log等选项)

gcc -E a.c --verbose > /dev/null

 

通过man gcc并搜索-I选项可得知头文件搜索的顺序

echo '#warning I am wrong!' > stdio.h
gcc -E a.c --verbose
mkdir aaa bbb
gcc -E a.c -Iaaa -Ibbb --verbose > /dev/null
echo '#warning I am wrong, too!' > bbb/stdio.h
echo '#define printf(...)' >> bbb/stdio.h
gcc -E a.c -Iaaa -Ibbb --verbose

类函数宏

#define max(a, b) ((a) > (b) ? (a) : (b))

预处理阶段只进行文本粘贴, 不求值

  • 小心优先级!
    • 好的编程习惯 -> 总是用括号包围参数
  • 小心副作用!
    • 好的编程习惯 -> 一个参数尽量不要展开多次
    • 如何实现?

 

#define max(a, b) ({ int x = a; int y = b; x > y ? x : y; })

上述代码使用了GNU dialect, 跨平台移植时需要注意

预处理的其他工作

  • 去掉注释
  • 连接因断行符(行尾的\)而拆分的字符串
  • 处理条件编译 #ifdef/#else/#endif
riscv64-linux-gnu-gcc -E a.c  # apt-get install g++-riscv64-linux-gnu
  • 字符串化 #
  • 标识符连接 ##
#define _str(x) #x
#define _concat(a, b) a##b
_concat(pr, intf)(_str(RISC-V));

 

更多信息请RTFM

IOCCC(国际混乱C代码大赛)

套路: 借助预处理机制编写不可读代码

2020年的一份获奖代码

                                                       #\
                                define C(c           /**/)#c
                               /*size=3173*/#include<stdio.h>
                            /*crc=b7f9ecff.*/#include<stdlib.h>
                           /*Mile/Adele_von_Ascham*/#include<time.h>
                           typedef/**/int(I);I/*:3*/d,i,j,a,b,l,u[16],v
                           [18],w[36],x,y,z,k;char*P="\n\40(),",*p,*q,*t[18],m[4];
                          void/**/O(char*q){for(;*q;q++)*q>32?z=111-*q?z=(z+*q)%185,(k?
                          k--:(y=z%37,(x=z/37%7)?printf(*t,t[x],y?w[y-1]:95):y>14&&y<33?x
                          =y>15,printf(t[15+x],x?2<<y%16:l,x?(1<<y%16)-1:1):puts(t[y%28])))
                          ,0:z+82:0;}void/**/Q(I(p),I*q){for(x=0;x<p;x++){q[x]=x;}for(;--p
    >1;q[p]=y)y          =q[x=rand()%-~p],q[x]=q[p];}char/**/n[999]=C(Average?!nQVQd%R>Rd%
  R%          %RNIPRfi#VQ}R;TtuodtsRUd%RUd%RUOSetirwf!RnruterR{RTSniamRtniQ>h.oidts<edulc
 ni                   #V>rebmun<=NIPD-RhtiwRelipmocResaelPRrorre#QNIPRfednfi#V__ELIF__R_
Re               nifed#V~-VU0V;}V{R= R][ORrahcRdengisnuRtsnocRcitatsVesle#Vfidne#V53556
 .           .1RfoRegnarRehtRniRre   getniRnaRsiR]NIP[R erehwQQc.tuptuoR>Rtxt.tupniR
 <         R]NIP[R:egasuV_Redulcn i#VfednfiVfednuVenife dVfedfiVQc%Rs%#V);I/**/main(
  I(      f),char**e){if(f){for(i=    time(NULL),p=n,q=  n+998,x=18;x;p++){*p>32&&!(
         *--q=*p>80&&*p<87?P[*p-   81]:*     p)?t  [( --  x)]=q+1:q;}if(f-2||(d=atoi
        (e[1]))<1||65536<d){;O("   \"");             goto  O;}srand(i);Q(16,u);i=0;Q(
       36,w);for(;i<36; i++){w[i]   +=w           [i]<26 ? 97:39; }O(C(ouoo9oBotoo%]#
      ox^#oy_#ozoou#o{ a#o|b#o}c#                o~d#oo-e   #oo.  f#oo/g#oo0h#oo1i#oo
     2j#oo3k#oo4l#o   p));for(j                   =8;EOF   -(i=   getchar());l+=1){a=1+
    rand()%16;for(b  =0;b<a||i-                           main   (0,e);b++)x=d^d/4^d/8^d/
    32,d=  (d/  2|x<<15)&65535;                          b|=   !l<<17;Q(18,v);for(a=0;a<18;
    a++     ){if( (b&(1<<(i=v[a]      ))))*                 m=75+i,O(m),j=i<17&&j<i?i:j;}O(C(
    !)           ); }O(C(oqovoo97o    /n!));i=           0;for(;i<8;O(m))m[2]=35,*m=56+u[i],m[1
    ]=          75   +i++;O(C(oA!oro   oqoo9)          );k=112-j*7;O(C(6o.!Z!Z#5o-!Y!Y#4~!X!X#3}
     !W  !W     #2    |!V!V#1{!U!U#0z!            T!T#/y!S!S#.x!R!R#-w!Q!Q#ooAv!P!P#+o#!O!O#*t!N!
       N#      oo       >s!M!M#oo=r!L!L#oo<q!K!K#   &pIo@:;= oUm#oo98m##oo9=8m#oo9oUm###oo9;=8m#o
               o9   oUm##oo9=oUm#oo98m####          o09]    #o1:^#o2;_#o3<o  ou#o4=a#o5>b#o6?c#o
             7@d#o8A e#o    9B    f#o:Cg#o;          D     h#o<Ei #o=Fj#o>   Gk#o?Hl#oo9os#####
           ));d=0                                          ;}          O:    for(x=y=0;x<8;++
          x)y|=                                                               d&(1<<u[x])?
          1<<                                                               x:0;return
           /*                                                               :9    */
            y                                                                ;    }

这其实是一个生成文本加密程序的程序 😂

  • 文本加密程序又通过预处理机制实现加密功能 😂
gcc a.c
echo 'Hello RISC-V!' | ./a.out 12345 > code.c
gcc -DPIN=12346 code.c -o test && ./test
gcc -DPIN=12345 code.c -o test && ./test

编译

编译是一个比较复杂的过程

词法分析 -> 语法分析 -> 语义分析 -> 中间代码生成 -> 优化 -> 目标代码生成

 

借助合适的工具(clang), 我们来看看每一个阶段都在做什么

  • clang功能上等价于gcc
#include <stdio.h>
int main() { // compute 1 + 2
  int x = 1, y = 2;
  int z = x + y;
  printf("z = %d\n", z);
  return 0;
}

词法分析

clang -fsyntax-only -Xclang -dump-tokens a.c

识别并记录源文件中的每一个token

  • 标识符, 关键字, 常数, 字符串, 运算符, 大括号, 分号…
  • 还记录了token的位置(文件名:行号:列号)
  • C代码 = 文本: 本质上是一个文本匹配程序

语法分析

clang -fsyntax-only -Xclang -ast-dump a.c

按照C语言的语法将识别出来的token组织成树状结构

  • AST(Abstract Syntax Tree), 可以反映出源程序的层次结构
  • 报告语法错误, 例如漏了分号

语义分析

按照C语言的语义确定AST中每个表达式的类型

  • 相容的类型将根据C语言标准规范进行类型转换
    • 算术类型转换
  • 报告语义错误
    • 未定义的引用
    • 运算符的操作数类型不匹配(如struct + int)
    • 函数调用参数的类型和数量不匹配

 

但大多数编译器并没有把词法分析, 语法分析, 语义分析严格按阶段进行

  • clang-ast-dump把语义信息也一起输出了
    • man clang可以得知clang的阶段划分

中间代码生成

clang -S -emit-llvm a.c

中间代码(也称中间表示, IR) = 一种编译器定义的, 面向编译场景的指令集

  • 本质上是编译过程中的一个抽象层, 基于抽象层进行优化很容易
  • 可以支持多种源语言和目标语言(硬件指令集)

clang使用的中间代码叫LLVM IR, gcc的叫GIMPLE

  • 我们不需要理解其中的细节, 研究它是编译专家的事情
    • 知道一些基本概念, 会连蒙带猜看一看即可(可RTFM了解更多)

优化

C语言标准对程序执行的要求很宽松

  • 可以严格按照语句的语义来执行(严格的状态转移)
  • 也可以不严格, 但需要满足程序的可观测行为(observable behavior of the program, C99 5.1.2.3节第6点)的一致性
    • 对volatile修饰变量的访问需要严格执行
    • 程序结束时, 写入文件的数据需要与严格执行时一致
    • 交互式设备的输入输出(stdio.h)需要与严格执行时一致

 

这给编译器优化提供了非常广阔的空间

  • 也是因为太广阔, 以至于编译器里面有很多bug
  • 理论上来说, “判断任意两个程序的可观测行为是否一致”是不可判定的
    • 如果这个问题可判定, 那么借助它可判定图灵停机问题(阅读材料)

优化(续)

例: 常数传播

clang -S -foptimization-record-file=- a.c
clang -S -foptimization-record-file=- a.c -O1

 

加个volatile试试

#include <stdio.h>
int main() { // compute 1 + 2
  int x = 1, y = 2;
  volatile int z = x + y;
  printf("z = %d\n", z);
  return 0;
}

 

查看clang进行的优化

clang -S -emit-llvm -mllvm -debug-pass=Arguments a.c -O3

目标代码生成

clang -S a.c
clang -S a.c --target=riscv64 -march=rv64g

将中间代码翻译成目标指令集

  • 将中间代码的变量映射到寄存器/内存
  • 将中间代码的操作映射到指令
  • 同时进行目标指令集相关的优化
    • 把经常使用的变量放到寄存器, 不太常用的变量放到内存
    • 选择指令数量较少的指令序列
    • 有很多优化的空间, 这里不深入讨论

 

可以通过time report观察clang尝试了哪些优化工作

clang -S a.c -ftime-report

踏入二进制的世界

汇编

clang -c a.c
clang -c a.c --target=riscv64 -march=rv64g
# alias rvclang="clang --target=riscv64 -march=rv64g"

根据指令集手册, 把汇编代码(指令的符号化表示)翻译成二进制目标文件(指令的编码表示)

 

二进制文件不能用文本编辑器打开来阅读了

  • 需要binutils(Binary Utilities)或者llvm的工具链
objdump -d a.o
riscv64-linux-gnu-objdump -d a.o
llvm-objdump -d a.o # llvm的工具链可以自动识别目标文件的架构, 用起来更方便

链接

clang a.c
riscv64-linux-gnu-gcc a.c # clang默认调用链接器的参数有点问题, 这里还是用gcc来演示
# apt-get install g++-riscv64-linux-gnu

合并多个目标文件, 生成可执行文件

  • 哪里来的多个目标文件呢?

 

让我们来看日志!

clang a.c --verbose
clang a.c --verbose 2>&1 | tail -n 1 | tr ' ' '\n' | grep '\.o$'

有很多crtxxx.o的文件

  • crt = C runtime, C程序的运行时环境(的一部分)
  • 可以通过objdump确认

问题: printf()的代码在哪里呢?

执行

./a.out

# 通过一些配置工作, RISC-V的可执行文件也可以在本地执行
# apt-get instal qemu-user qemu-user-binfmt
# mkdir -p /etc/qemu-binfmt
# ln -s /usr/riscv64-linux-gnu/ /etc/qemu-binfmt/riscv64
file a.out
a.out: ELF 64-bit LSB pie executable, UCB RISC-V, version 1 (SYSV)...
./a.out # 实际上是在QEMU模拟器中执行

把可执行文件加载到内存, 跳转到程序, 执行编译出的指令序列

 

Q: 谁来加载?

A: 运行时环境(宿主操作系统/QEMU)

 

关于执行的更多内容将在下次课介绍

实现定义行为和ABI

一个案例

printf(6 - 2147483648 > 6 ? "T" : "F");
printf(6 - 0x80000000 > 6 ? "T" : "F");
printf("\n");

c90/c99和32位/64位组合下的结果

clang -w -std=c90 -m32 a.c && ./a.out
clang -w -std=c99 -m32 a.c && ./a.out
clang -w -std=c90 -m64 a.c && ./a.out
clang -w -std=c99 -m64 a.c && ./a.out

 

32位 64位
c90 TT FT
c99 FT FT

正确做法: 通过日志观察工具的行为

RTFM: 2147483648究竟如何被识别

2147483648属于无后缀十进制数

  • C90: 6.1.3.2 Integer constants
    • 第一个可以表示的类型: int, long, unsigned long
  • C99: 6.4.4.1 Integer constants
    • 第一个可以表示的类型: int, long, long long

2147483648clang语法树中的类型

32位 64位
c90 unsigned long long
c99 long long long

假设clang的解析是对的

  • 减一个无符号数, 得到一个很大的正数(2^31 + 6), 不等式成立
  • 减一个有符号数, 得到一个负数, 不等式不成立

根据clang的解析结果得到一个猜想

  • long在32位环境下长度是32位, 在64位环境下长度是64位

怎么验证/推翻这个猜想?

 

动手写个小程序就可以啦

int main() {
  printf("%zu\n", sizeof(long));
  return 0;
}
clang -w -m32 a.c && ./a.out
clang -w -m64 a.c && ./a.out

 

表面上看是这样, 但C语言标准真的是这么说的吗?

再次RTFM

5.2.4.2 Numerical limits

An implementation is required to document all the limits specified in this
subclause, ...

5.2.4.2.1 Sizes of integer types <limits.h>

... Their implementation-defined values shall be equal or greater in magnitude
(absolute value) to those shown, with the same sign.

C语言标准并没有明确定义类型的长度, 而是全部交给具体实现来定义

  • 不过定义了类型的最小范围
// 假设1字节 = 8比特
sizeof(  signed char     ) >= 1
sizeof(unsigned char     ) >= 1
sizeof(  signed short    ) >= 2
sizeof(unsigned short    ) >= 2
sizeof(         int      ) >= 2
sizeof(unsigned int      ) >= 2
sizeof(         long     ) >= 4
sizeof(unsigned long     ) >= 4
sizeof(         long long) >= 8
sizeof(unsigned long long) >= 8

为什么会这样?

C99的Abstract

... Its purpose is to promote portability, reliability, maintainability, and
efficient execution of C language programs on a variety of computing systems.
  • 要支持现有的计算机系统, 于是很多规定不能说太死
3.6 byte

NOTE 2   A byte is composed of a contiguous sequence of bits, the number of which is
implementation-defined
  • 要支持将来的计算机系统, 于是很多规定也要给未来留个口
    • 所以只定义了类型的最小范围
      • Turbo C(DOS环境下的C编程IDE)中的int是16位
      • VC 6.0中的int是32位
      • 不过如今的64位环境中, int还是32位

未指定行为(Unspecified Behavior)

use of an unspecified value, or other behavior where this International Standard
provides two or more possibilities and imposes no further requirements on which is
chosen in any instance

C标准提供了多种行为可选, 具体实现需要选择

 

例: 函数调用时参数求值顺序是unspecified

#include <stdio.h>
void f(int x, int y) {
  printf("x = %d, y = %d\n", x, y);
}
int main() {
  int i = 1;
  f(i ++, i ++);
  return 0;
}

包含这种行为的程序, 重新编译后可能无法得到正确的结果

  • 当年计算机二级考试有不少类似的题目 😂

实现定义行为(Implementation-defined Behavior)

unspecified behavior where each implementation documents how the choice is made

一类特殊的未指定行为, 具体实现需要将选择写到文档里

  • 写进文档之后就不能随便改了
  • 若专门为某特定系统开发程序, 用户可以假设这些选择成立

 

例: 类型的长度

assert(sizeof(long) == 4); // 在某特定32位系统上总是成立

包含这种行为的程序, 在相同的环境下运行可以得到相同的结果

  • 但在移植到另一个环境时可能会出现问题

未定义行为(Undefined Behavior)

behavior, upon use of a nonportable or erroneous program construct or of erroneous
data, for which this International Standard imposes no requirements

程序/数据不符合标准的行为

  • 完全没说会发生什么, 一切皆有可能

 

例: 缓冲区溢出

#include <stdio.h>
int main() {
  int a[10] = {0};
  int x = a[-2];
  printf("x = %d\n", x);
  return 0;
}

包含这种行为的程序, 多次运行可能也无法得到正确的结果

实现定义行为的选择写到了哪个文档里?

C99关于implementation的定义(第5节):

An implementation translates C source files and executes C programs in two
data-processing-system environments, which will be called the translation environment
and the execution environment in this International Standard...

在真实的系统中, 这相当于编译器+(处理器+操作系统+库函数)

  • 三者需要对implementation-defined behavior达成一致的约定

这就是ABI(Application Binary Interface), 具体包含

  • 处理器的指令集, 寄存器结构, 栈的组织, 访存类型等
  • 处理器可直接访问的基本数据类型的大小, 布局, 对齐方式
  • 调用约定, 用于规定函数的参数如何传递, 返回值如何获取
  • 应用程序如何向操作系统发起系统调用
  • 目标文件的格式, 支持的库函数等

ABI手册是计算机系统软硬件协同的重要体现

RTFM: ABI中定义的基本数据类型

 

Q: 如何使用跨平台固定长度的数据类型?

A: #include <stdint.h>

  • 运行时环境的库会帮我们定义成正确的类型
int8_t;
int16_t;
int32_t;
int64_t;
uint8_t;
uint16_t;
uint32_t;
uint64_t;

另一个例子

char c = 0xff;
printf(c == 0xff ? "T" : "F");
printf("\n");

 

Q: ???

A: char的符号也是implementation-defined的

  • 启示: 不要直接用char来进行算术运算
    • signed charunsigned char

总结

从C代码到指令序列

  • 预处理 -> 编译 -> 汇编 -> 链接 -> 执行
  • 编译 = 词法分析 -> 语法分析 -> 语义分析 -> 中间代码生成 -> 优化 -> 目标代码生成
  • 学会使用工具和日志理解这个过程中发生的细节

 

  • C语言标准中除了确切的行为, 还包含
    • Unspecified Behavior
    • Implementation-defined Behavior
    • Undefined Behavior
  • 通过ABI手册了解Implementation-defined Behavior的选择
    • 同时认识C语言标准, 编译器, 操作系统, 库函数, 处理器之间的协助