
南大 ICS

nju ics
南京大学计算机系统基础习题课 Jyy
C语言拾遗 - 机制
- 编译、链接
- .c -> 预编译 -> .i -> 编译 -> .s -> 汇编 -> .o -> 链接 -> a.out
- 加载执行
./a.out
RTFM: man gcc
better: tldr
一些命令行工具
- file
- readelf
- objdump
- …
GNU BinUtils
预处理器
#include<> 指令
1 |
#include 指令本质上就是在预处理阶段进行粘贴
看看下面这段有趣的代码:
1 |
|
1 | // hello.h |
- <>: 优先在系统级目录下寻找头文件,如果找不到再到当前目录下寻找
- 可以使用
gcc --verbose
来查看到底查找了哪些目录
- 可以使用
- “ “: 优先在当前目录下寻找
上面两种情况假设我们没有在编译时通过 -I 来指定头文件目录
例子
1 |
|
上面这段代码会输出 Yes,这是因为在C预处理器中,如果一个宏没有被定义,那么它在条件编译表达式中的值会被视为0
#if aa == bb
被替换成了#if 0 == 0
,这是一个true条件
如何躲过 Online Judge 的关键字过滤
1 |
## 宏拼接
如何毁掉一个身边的同学
1 |
X-Macros
1 |
|
发生在实际编译之前
- 也成为元编程(meta-programming)
- gcc的预处理器同样可以处理汇编代码
- C++中的模板元编程,Rust的macros等
编译和链接
C是高级的汇编语言
链接
将多个二进制目标文件拼接到一起
- C中的编译单元( compilation unit )
- 甚至可以链接C++, Rust, …
加载
C程序执行的两个视角
静态:C代码的连续一段总能对应到一段连续的机器指令
动态:C代码执行的状态总能对应到机器的状态
- 源代码视角
- 函数、变量、指针…
- 机器指令视角
- 寄存器、内存、地址
两个视角的共同之处
- 代码、变量(源代码视角)= 地址 +长度(机器指令视角)
- (不太严谨的)内存 = 代码 +数据 +堆栈
从main函数开始
谁调用的main?进程执行的第一条指令是什么?
第一个问题,谁调用了main
main
实际上是由C运行库中的启动代码(startup code)调用的,这个启动代码代码程序的执行入口点(entry point),通常是名为_start
的函数
程序的执行从_start
开始,它负责执行所有必要的运行时初始化,包括设置堆栈、初始化全局和静态变量、调用全局构造函数等,然后调用main
函数
第二个问题,进程执行的第一条指令是什么
- 进程执行的第一条指令是在
_start
函数中,这是由C运行时库提供的,不是开发者编写的C代码的一部分。_start
是程序与操作系统交互的桥梁,它由操作系统在加载程序到内存并准备执行时设置。_start
函数的具体职责包括:- 初始化程序执行环境
- 调用全局和静态变量的构造函数(在C++中)
- 设置参数(
argc
、argv
)以供main
函数使用 - 调用
main
函数,并传递适当的参数 - 捕获
main
函数的返回值,并将其传递给操作系统(通过调用exit
函数或类似机制)
1 | int main(int argc, cahar *argv[]); |
- argc: 参数个数
- argv: 参数列表
C Type System
1 |
|
C语言拾遗 - 编程实践
例子
人类不可读版本
1 | void (*signal(int sig, void (*func)(int)))(int); |
人类可读版本
1 | typedef void (*sighandler_t)(int); |
数字逻辑电路模拟器
状态模拟 +计算模拟
1 |
|
编写代码的准则:降低维护成本
Programs are meant to be read by humans and only incidentally for computers to execute. – D.E.Knuth
Y-Emulator
存储是计算机能实现“计算”的重要基础
- 寄存器、内存
减少dependencies -> 降低代码耦合程度
代码实现和优化部分建议看原视频
框架代码选讲 - 编译运行
有个叫 -rf
的文件如何删除
显然rm -rf
不起作用,使用rm -- rf
,其中--
会将其后参数解释为文件名
大概就是从make(构建工具)的角度来解析项目结构,从有向无环图的角度来解释make构建的目标依赖
UNIX哲学可以帮助你更好的阅读代码
基本原则:任何感到不爽的事情一定有工具能帮你
代码框架选讲 - 代码导读
建议看原视频
数据的机器级表示 - 整数和浮点数
位运算
位运算有很多骚操作,感兴趣的可以做一下cmu 15-213的data lab
这里老师推荐了一本书:Hacker’s Delight
IEEE 754是一个非常复杂的标准,这里Jyy举了一个倒速平方根的例子(出自雷神之锤III)
1 | float Q_rsqrt( float number ) { |
汇编 /内联汇编选讲 - x86-64
书籍推荐:Richard.Blum 编程语言程序设计
关于int类型的长度
- 8 bit computer: 8 bit
- 16 bit computer: 16bit
- 32 bit computer: 32 bit
- 64 bit computer: 32 bit
- JVM (32/64 bit): 32 bit
ABI
只要遵循某种标准的二进制文件就可以相互调用
区别于API (Application Programming Interface)
- 程序源代码中的规范
ABI: 约束binary的行为
- 二进制文件的格式
- 函数调用、系统调用
- C语言规范只定义了运行时内存和内存上的计算
- printf都无法实现,必须借助外部库函数
- 链接、加载的规范
例子: cdecl 函数调用
caller stack frame:
- 所有参数以数组的形式保存在堆栈上 (反序压栈)
- 返回地址
- 跳转到callee
callee:
- EAX作为返回寄存器
- 其他寄存器都是callee save
跳转采用的是相对地址,且相对位置为当前指令的尾部,即下一条指令的开头
e.g.
1 | void bar(int *); |
1 | gcc -m32 -O2 -fno-pic -c a.c |
我们可以得到非常易读的汇编
1 | Disassembly of section .text: |
SAT Solver -> SMT Solver
寄存器与函数调用
Q: 为什么x86-64要采用寄存器传参?
- 简化代码
- 减少访存
- 编译器有了更大的优化空间
Inline Assembly
编译器:把C代码“原样翻译”成汇编代码
1 | int foo(int x, int y) { |
1 | gcc -O0 -c a.c -o a.o && objdump -d a.o |
1 | Disassembly of section .text: |
e.g.
1 | int foo(int x, int y) { |
1 | gcc -O0 -c a.c -o a.o && objdump -d a.o |
1 | Disassembly of section .text: |
链接和加载
推荐: GNU Binutils
一个最小的可执行程序
e.g.
1 | mov $0xe7, %rax |
返回 1
1 |
|
动态链接
Q: 为什么需要动态链接?
- 静态链接导致可执行文件太大了
操作系统启动动态链接依赖的可执行文件
通过
/lib64/ld-linux-x86-64.so.2
也就是说我们在执行具有动态链接依赖的可执行文件时
./a.out
和/lib64/ld-linux-x86-64.so.2 ./a.out
是等价的
动态链接的实现
AbstractMachine
主要讲了计算机的发展历史,建议看原视频
I/O设备
两个特殊的I/O设备
总线
- 系统里可能有很多(甚至是可变的)I/O设备
- 总线实现了设备的查找、映射、命令/数据的转发
- CPU可以只直接连接到总线
- 总线可以连接其他总线
- 例子:
lspci -t, lsusb -t
中断控制器
- 管理多个产生中断的设备
- 汇总成一个中断的设备
- 支持中断表的屏蔽、优先级管理等
调试:理论与实践
已知程序有bug,如何找到?
调试理论
实际中的调试:通过观察程序执行的轨迹(trace)
- 缩小错误状态 (error) 可能产生的位置
- 作出适当的假设
- 再进行细粒度的定位和诊断
最重要的两个工具
- printf -> 自定义log的trace
- 灵活可控、能快速定位问题大概位置、适用于大型软件
- 无法精确定位、大量的logs管理起来比较麻烦
- gdb -> 指令/语句级trace
- 精确、指令级定位、任意查看程序内部状态
- 耗费大量时间
调试理论:应用
问题诊断其实也是调试
需求 -> 设计 -> 代码 -> Fault -> Error -> Failure
什么是好的程序?
- 不言自明:能知道是做什么的(specification)
- 代码风格很重要
- 不言自证:能确认代码和specification一致
- 代码中的逻辑流很重要
程序首先是给人读的,其次才是被机器执行的
- 写好读、易验证的代码
- 在代码中添加更多的断言
断言的意义
把代码中隐藏的 specification 写出来
- Fault -> Error (靠测试)
- Error -> Failure (靠断言)
- Erro 暴露的越晚,越难调试
- 追溯导致assert failure的变量值(slice)通常可以快速定位到bug
例子:维护父亲节点的平衡树
e.g.
1 | // 结构约束 |
一个神奇的编译选项
- -fsanitize-address
- Address Sanitizer,动态程序分析
系统编程与基础设施
论软件自动化可以帮你节省多少时间
gdb有外部接口,可以远程调试
Differential Testing
感觉和对拍差不多
中断与分时多任务
有时把异常成为同步中断
x86-64中断过程
比函数调用复杂一些
- 函数调用需要保存PC到堆栈 (%rsp)
- 但中断不仅要保存PC (尤其是在有特权级切换的时候)
中断处理
- 自动保存 RIP, CS, RFLAGS, RSP, SS (Error Code)
- 根据中断/异常号跳转到处理程序(特权级切换会触发堆栈切换)
- int &$0x80指令可以产生128号异常
- 时钟会产生32号中断
- 键盘会产生33号中断
- …