Learn C the Hardway学习笔记和拓展知识(一)
以下是《笨方法学C语言》个人学习笔记,以便日后翻阅
文章目录
- [Learn C the Hardway学习笔记和拓展知识(一)](#Learn C the Hardway学习笔记和拓展知识(一))
-
- 导言
- 练习0:准备
- 练习1:启动编辑器
- 练习2:用Make来代替Python
- 练习3:格式化输出
- [练习4:Valgrind 介绍](#练习4:Valgrind 介绍)
- 练习5:一个C程序的结构
- 练习6:变量类型
- 练习7:更多变量和一些算术
- 练习8:大小和数组
- 练习9:数组和字符串
导言
你会学到什么?
- C的基本语法和编写习惯。
- 编译,make文件和链接。
- 寻找和预防bug。
- 防御性编程实践。
- 使C的代码崩溃。
- 编写基本的Unix系统软件。
练习0:准备
安装依赖:
bash
sudo apt-get install build-essential
编辑器:使用Vim,或者使用Nano(小白友好)
关于Vim的使用可以参看博主的Linux命令行系列笔记
练习1:启动编辑器
make工具
这一部分需要我们熟悉一下make工具。
make是一个在软件开发中被广泛使用的自动化构建工具。它通过读取一个名为Makefile的脚本文件,来高效、智能地管理和执行项目的编译、链接等任务。
make是工具,而Makefile是文件
关于Makefile我们之后再说,这是make之所以高效的精髓所在。这里我们先聚焦于make的使用。
make工具的基本使用方法
- 执行默认任务
这是最基本、最常用的命令。在项目根目录下,不带任何参数执行make。
Bash
$ make
作用:此命令会执行Makefile中定义的默认目标。按照惯例,默认目标通常是编译整个项目,生成最终的可执行文件或库。
注意:make会自动检查源文件和目标文件的修改时间。如果它发现源文件(如 .c 文件)比目标文件(如 .o 或可执行文件)要新,它才会执行编译命令。如果所有文件都是最新的,它会提示"目标已是最新",什么也不做,从而极大地节省了时间。
如果没有Makefile,make工具会尝试使用内置的隐式规则构建可执行文件,但如果隐式规则执行失败就会报错。
- 执行指定任务
您可以明确告诉make您想执行哪一个具体任务。
Bash
# 执行名为"clean"的任务
$ make clean
# 执行名为"install"的任务
$ make install
# 执行名为"test"的任务
$ make test
这些是Makefile中常见的预定义任务,需要你在Makefile里进行编写
作用:此命令会执行Makefile中与指定目标(clean, install, test)相对应的规则。
- clean:清理项目目录,删除所有编译过程中产生的中间文件和最终产物。
- install:将编译好的程序或库安装到系统中指定的位置(如 /usr/local/bin)。
- test:运行项目的单元测试或集成测试。
提示:您可以通过Tab键的自动补全功能来查看一个项目支持哪些任务目标(需要shell环境配置支持)。输入 make 然后按两下 Tab 键,通常会列出所有可用的目标。
- 执行多个任务
您可以让make按顺序连续执行多个任务。
Bash
$ make clean all
作用:make会按照您给出的顺序,依次执行每个任务。在上述例子中,它会先执行clean任务来删除旧文件,然后执行all任务(all通常是默认任务的别名),从一个干净的状态重新编译整个项目。这在您想要彻底重新构建时非常有用。
make工具的常见参数
- -j 或 --jobs= (并行构建)
这是提升编译效率最重要的参数。它让make可以同时执行N个命令。
Bash
# 使用 8 个线程并行编译
$ make -j 8
# 让 make 自行决定并行任务数(通常是CPU核心数)
$ make -j
适用场景:在多核CPU的机器上编译大型项目,可以成倍缩短等待时间。
- -n 或 --just-print (空运行/演习模式)
只打印出将要执行的命令,但并不真正执行它们。
Bash
$ make -n install
作用:在执行一个有潜在风险或不确定的任务(如install)之前,用此参数可以预先查看make到底打算执行哪些shell命令,以确保一切符合预期。是调试Makefile和安全操作的利器。
- -k 或 --keep-going (持续执行)
在构建过程中,如果某个命令出错,make默认会立即停止。使用此参数后,如果某个分支的构建失败,make会继续尝试构建其他不依赖于失败分支的目标。
- -C <dir> 或 --directory=<dir> (指定目录)
在执行任何操作前,先切换到 <dir>
目录。
bash
$ make -C /path/to/project/subdir
作用 :让您可以从任何位置去调用特定子目录中的make
任务,而无需先cd
过去。在编写更复杂的构建脚本时非常有用。
- -B 或 --always-make (强制重新构建)
无条件地认为所有目标都已过期,强制重新构建所有东西。
bash
$ make -B
作用 :当您怀疑文件的时间戳可能不正确,导致make
没有重新编译应更新的文件时,可以使用此参数强制进行一次完整的构建。
- -f <file>或 --file=<file> (指定Makefile文件)
使用一个非标准名称的Makefile
文件。make
默认会寻找GNUmakefile
, makefile
, Makefile
。
bash
$ make -f MyMakefile.mk
作用 :当项目中有多个Makefile
,用于不同的构建场景(如生产环境、测试环境)时,可以用此参数来选择。
习题
都比较简单,值得说明一下的是man 3 puts里的3的含义:这是手册的章节编号。man的手册被分成了不同的章节,以便对内容进行分类。章节编号3里写的是库函数。
这一章专门收录C语言标准库(libc)或者其他库(如数学库libm)提供的函数。例如 printf, malloc, strcpy, 以及我们这里的 puts 都属于这一章。
练习2:用Make来代替Python
Makefile
Makefile的基本结构
Makefile的基本语法:
Makefile
<目标 (Target)>: <依赖 (Prerequisites)>
<Tab><命令 (Command)>
-
目标 (Target)
- 通常是一个文件名,是你希望生成的东西,比如可执行文件my_app或目标文件main.o。
- 也可以是一个"动作"的名称,比如clean,这种不代表实际文件的目标被称为"伪目标"(Phony Target)。
-
依赖 (Prerequisites)
- 生成该"目标"所需要的一个或多个文件(或其他目标)。
- make会检查"目标"文件和"依赖"文件的时间戳。如果任何一个依赖比目标更新(或者目标文件不存在),make就会执行下面的命令来重新生成目标。
-
命令 (Command)
用于从"依赖"生成"目标"的Shell命令。
极其重要!!! 每条命令都必须以一个 制表符(Tab) 开头,绝不能是空格!这是初学者最常犯的错误,会导致make报missing separator. Stop.的错误。
一个示例:
Makefile
# 目标是hello, 依赖是hello.c
hello: hello.c
# 命令是使用gcc编译hello.c生成hello
gcc hello.c -o hello
Makefile的核心语法
- 变量
定义变量:
- =(延时展开):变量的值在使用时才被确定。
- :=(立即展开):变量的值在定义时就被立即确定。推荐优先使用:=,因为它的行为更直观,可以避免由延时展开引起的潜在问题(如无穷递归)。
- ?=(条件赋值):如果变量尚未定义,则为它赋值。如果已定义,则什么也不做。
- +=(追加赋值):为变量追加内容。
使用变量:通过 (VAR_NAME) 或 {VAR_NAME} 来引用。
例子:
Makefile
# 定义变量 (推荐使用 :=)
CC := gcc
CFLAGS := -Wall -g # -Wall: 显示所有警告, -g: 添加调试信息
TARGET := my_app
SRCS := main.c utils.c
OBJS := $(SRCS:.c=.o) # 这是一个高级用法,将.c后缀替换为.o
# 第一个目标通常是 'all',它依赖于最终的可执行文件
all: $(TARGET)
# 链接规则:从所有.o文件生成最终目标
$(TARGET): $(OBJS)
$(CC) $(CFLAGS) -o $(TARGET) $(OBJS)
# 编译规则:从.c文件生成.o文件
# 这里可以为每个文件写一条规则,但非常繁琐
main.o: main.c
$(CC) $(CFLAGS) -c main.c
utils.o: utils.c
$(CC) $(CFLAGS) -c utils.c
自动化变量
在规则的命令中,可以使用自动化变量来指代目标和依赖,极大简化写法。
- $@:表示规则中的目标文件名。
- $^:表示规则中的所有依赖文件列表(以空格分隔,无重复)。
- $<:表示规则中的第一个依赖文件名。
- $?:表示所有比目标新的依赖文件列表。
- 规则模式
为了避免给每一个.c文件都写一条编译规则,我们可以使用模式规则,用%作为通配符。
Makefile
# 模式规则:定义了如何从任一.c文件生成对应的.o文件
%.o: %.c
# $@ 代表目标 (如 main.o)
# $< 代表第一个依赖 (如 main.c)
$(CC) $(CFLAGS) -c $< -o $@
有了这条模式规则,上面例子中针对main.o和utils.o的单独规则就可以全部删除了,make会自动应用它。
- 伪目标(.PHONY)
伪目标不代表真实文件,它只是一个执行命令的标签。使用.PHONY声明一个目标,可以:
- 避免当目录下恰好有一个同名文件时,导致命令无法执行的问题。
- 提高性能,因为make不会去检查该文件是否存在或其时间戳。
常见的几个伪目标:
all:
- 作用:作为Makefile的默认入口,用于编译整个项目的主要部分。它通常是Makefile中的第一个目标,这样当用户只输入make而不带任何参数时,就会默认执行all。
- 写法:all的依赖通常是项目最终要生成的那些可执行文件或库文件。
Makefile
.PHONY: all
all: my_app my_library.so
my_app: main.o utils.o
$(CC) -o $@ $^
my_library.so: feature.o
$(CC) -shared -o $@ $^
# ... 其他编译规则 ...
clean:
- 作用:清理构建产物。删除所有由make命令创建的中间文件(如.o文件)和最终产品(可执行文件、库文件等),但保留源代码和配置文件。
- 写法:命令通常是rm -f ...。使用-f参数可以确保在文件不存在时命令不会报错。
Makefile
.PHONY: clean
clean:
rm -f *.o my_app my_library.so
install:
- 作用:安装程序。将编译好的可执行文件、库文件、文档等拷贝到系统的标准位置(如/usr/local/bin, /usr/local/lib等)。
- 写法:命令通常是cp或install命令。由于安装到系统目录通常需要管理员权限,所以命令前有时会加上sudo,或者在文档中提示用户使用sudo make install。
Makefile
.PHONY: install
install: all
install -m 755 my_app /usr/local/bin/
install -m 644 my_library.so /usr/local/lib/
注意:install目标通常依赖于all,以确保在安装前所有东西都已被正确编译。
uninstall:
- 作用:卸载程序。install的逆操作,从系统中删除由make install安装的文件。提供这个目标是一个非常好的习惯。
Makefile
.PHONY: uninstall
uninstall:
rm -f /usr/local/bin/my_app
rm -f /usr/local/lib/my_library.so
test或check:
- 作用:运行测试。执行项目的单元测试、集成测试或任何自动化测试脚本。
Makefile
.PHONY: test
test: all
./run_tests.sh
# 或者直接调用测试程序
./my_app --test
- 内置函数
Makefile提供了一些内置函数,用于处理文件名和字符串。
- (wildcard PATTERN):查找匹配PATTERN的所有文件。 示例:SRCS := (wildcard *.c) 会自动获取当前目录下所有的.c文件。
- (patsubst PATTERN, REPLACEMENT, TEXT):模式字符串替换。 示例:OBJS := (patsubst %.c, %.o, $(SRCS)) 会将SRCS变量中所有以.c结尾的字符串替换为以.o结尾。
- 隐藏命令(@)和忽略错误(-)
- 在命令前加上@,make在执行时将不会打印该命令本身,只会显示其输出。这可以让构建日志更干净,如@echo "Linking..."。
- 在命令前加上-,make会忽略该命令的执行错误,继续执行后续任务。最常见的用法是clean目标中的-rm,这样即使没有文件可供删除,make也不会报错。
- 条件判断
Makefile里的条件判断主要用于:
- 适应不同操作系统:根据当前是Linux、macOS还是Windows,执行不同的命令或使用不同的编译选项。
- 区分不同构建类型:轻松实现"调试版(Debug)"和"发行版(Release)"的切换。调试版可能需要加入调试符号,而发行版需要加入优化选项。
- 启用或禁用可选功能:根据用户的选择,决定是否编译某个可选模块或链接某个可选库。
- 提供默认配置:如果用户没有指定某个变量(如编译器),则为其设置一个合理的默认值
两种基本判断结构
- ifeq和ifneq
用于判断两个参数是否相等。这是最常用的判断指令。
Makefile
ifeq (arg1, arg2)
# 如果 arg1 和 arg2 相等,则执行这里的语句
else
# 否则
endif
ifneq用于判断两个参数是否不相等,与ifeq逻辑相反。
- ifdef和ifndef
用于判断一个变量是否已经被定义。只要变量有值(即使是空值,通过VAR=定义),ifdef就判断为真。
Makefile
ifdef variable_name
# 如果 variable_name 已被定义,则执行这里的语句
endif
用于判断一个变量是否尚未被定义,与ifdef逻辑相反。
注意:
- else: 提供"否则"的分支。一个if语句块中最多只能有一个else。
- endif: 必须有! 每一个if...语句块都必须以一个endif结尾,标志着条件判断的结束。
一个示例,用于处理跨平台兼容:
Makefile
# 使用make内置变量OS,它通常在Windows上为"Windows_NT"
ifeq ($(OS), Windows_NT)
RM := del /Q
EXE_EXT := .exe
# 在Windows上可能不需要链接特殊库
LIBS :=
else
RM := rm -f
EXE_EXT :=
# 在Linux/macOS上需要链接pthread库
LIBS := -lpthread
endif
TARGET := my_app$(EXE_EXT)
all: $(TARGET)
$(TARGET): main.o
$(CC) -o $@ $^ $(LIBS)
# ...
.PHONY: clean
clean:
$(RM) $(TARGET) *.o
Makefile的常用模板
Makefile
# =================================================================================== #
# Makefile 通用模板 #
# =================================================================================== #
# ========================= 1. 项目基础配置 ============================== #
# 用户主要修改此区域
# 1.1) 定义最终生成的可执行文件名
TARGET := my_app
# 1.2) 定义源文件目录和文件扩展名
# (自动发现所有.c或.cpp文件)
SRC_DIR := src
SRC_EXT := .c
SRCS := $(wildcard $(SRC_DIR)/*$(SRC_EXT))
# 1.3) 定义目标文件(.o)的输出目录
# (根据源文件自动生成对应的.o文件名列表)
OBJ_DIR := obj
OBJS := $(patsubst $(SRC_DIR)/%$(SRC_EXT), $(OBJ_DIR)/%.o, $(SRCS))
# 1.4) 定义头文件目录
INC_DIR := include
INC_FLAGS := -I$(INC_DIR)
# ========================= 2. 编译器与编译选项 =========================== #
# 2.1) 定义编译器
CC := gcc
CXX := g++
# 2.2) 定义编译和链接选项
# CFLAGS: C文件编译选项
# CXXFLAGS: C++文件编译选项
# LDFLAGS: 链接器选项 (如-L/path/to/libs)
# LIBS: 需要链接的库 (如-lm, -lpthread)
# DEFINES: 预定义的宏 (如-DDEBUG)
CFLAGS := -Wall -g
CXXFLAGS := $(CFLAGS) -std=c++11
LDFLAGS :=
LIBS :=
DEFINES :=
# ========================= 3. 构建模式 (调试 vs 发行) ========================= #
# 通过 `make DEBUG=1` 来启用调试模式
# 默认为发行模式 (Release Mode)
BUILD_MODE := Release
CFLAGS += -O2
CXXFLAGS += -O2
# 如果检测到 "DEBUG=1", 则切换到调试模式 (Debug Mode)
ifeq ($(DEBUG), 1)
BUILD_MODE := Debug
# 调试模式下,使用-g选项并定义DEBUG宏
CFLAGS = -Wall -Wextra -g
CXXFLAGS = $(CFLAGS) -std=c++11
DEFINES += -DDEBUG
# 可以给调试版的目标文件加一个后缀
TARGET := $(TARGET)_debug
endif
# ========================= 4. 核心构建规则 =========================== #
# 伪目标声明
.PHONY: all clean rebuild install uninstall help
# ---- 默认目标: all ----
# 第一个目标是默认目标,当只输入`make`时执行
all: $(TARGET)
@echo "=========================================================="
@echo " Project: $(TARGET)"
@echo " Build Mode: $(BUILD_MODE)"
@echo " Build completed successfully!"
@echo "=========================================================="
# ---- 链接规则 ----
# 将所有的.o文件链接成最终的可执行文件
$(TARGET): $(OBJS)
@echo "-> Linking target: $@"
$(CC) $^ $(LDFLAGS) $(LIBS) -o $@
# ---- 编译规则 (模式规则) ----
# 定义如何从.c文件编译出.o文件
# 命令会为每个.o文件执行一次
$(OBJ_DIR)/%.o: $(SRC_DIR)/%$(SRC_EXT)
@echo "-> Compiling source: $<"
# 在编译前确保输出目录存在
@mkdir -p $(OBJ_DIR)
$(CC) $(CFLAGS) $(DEFINES) $(INC_FLAGS) -c $< -o $@
# ========================= 5. 其他常用伪目标 =========================== #
# ---- 清理目标 ----
clean:
@echo "-> Cleaning project..."
-rm -rf $(OBJ_DIR) $(TARGET) $(TARGET)_debug
@echo "-> Clean complete."
# ---- 强制重新构建 ----
rebuild: clean all
# ---- 安装目标 ----
install: all
@echo "-> Installing $(TARGET)..."
# 这里需要根据你的需求修改安装路径和权限
# sudo install -m 755 $(TARGET) /usr/local/bin/
# ---- 卸载目标 ----
uninstall:
@echo "-> Uninstalling $(TARGET)..."
# sudo rm -f /usr/local/bin/$(TARGET)
# ---- 帮助目标 ----
help:
@echo "Usage: make [TARGET]"
@echo "----------------------------------------------------------"
@echo "Available targets:"
@echo " all - Build the project (default)."
@echo " clean - Remove all build artifacts."
@echo " rebuild - Clean and then build the project."
@echo " install - Install the application (requires permission)."
@echo " uninstall - Uninstall the application (requires permission)."
@echo ""
@echo "Options:"
@echo " DEBUG=1 - Build in debug mode."
@echo " Example: make DEBUG=1"
@echo "----------------------------------------------------------"
模板使用的目录结构如下,当然也可以直接在模板中的参数中进行修改。
my_project/
├── Makefile
├── src/
│ ├── main.c
│ └── utils.c
└── include/
└── utils.h
修改配置:
- 将 TARGET := my_app 修改为你想要的可执行文件名。
- 如果你的源文件扩展名不是 .c,修改 SRC_EXT。
- 如果需要链接其他库,在 LIBS 后面添加,例如 LIBS := -lm -lpthread。
执行命令:
- make: 编译生成发行版 my_app。
- make DEBUG=1: 编译生成调试版 my_app_debug。
- make clean: 清理所有生成的文件。
- make rebuild: 强制重新编译所有文件。
- make help: 查看所有可用的命令。
习题
定义all后直接使用make进行项目构建:
Makefile
CC=gcc
CFLAGS=-Wall -g
all:ex1
ex1:ex1.o
${CC} $^ ${CFLAGS} -o $@
ex1.o:ex1.c
${CC} ${CFLAGS} -c $< -o $@
clean:
-rm -f ex1 ex1.o
练习3:格式化输出
printf函数
C语言中的格式化输出主要通过printf函数来实现,printf 函数是 "print formatted" 的缩写,它定义在标准输入输出头文件 <stdio.h> 中。
基本语法如下:
c
printf("格式控制字符串", 参数1, 参数2, ...);
- 格式控制字符串 (Format Control String): 这是一个字符串,包含了两种内容:
- 普通字符: 这些字符会原样输出到屏幕上。
- 格式占位符 (Format Specifier): 这些字符以 % 开头,用于为后续的参数指定一个"位置"和输出的"格式"。
- 参数列表 (Argument List): 这是要输出的变量、常量或表达式的列表。参数的数量必须与格式控制字符串中占位符的数量相匹配,并且类型也要对应。
常见占位符
占位符 | 对应数据类型 | 描述 | 示例代码 |
---|---|---|---|
%d 或 %i | int | 用于输出一个有符号的十进制整数。 | int age = 30; printf("年龄: %d\n", age); |
%f | float, double | 用于输出一个十进制浮点数(小数)。默认显示6位小数。 float pi = 3.14159; printf("Pi: %f\n", pi); | |
%e 或 %E | float,double | 以科学技术法输出一个数字 | double num = 12345.6789; printf("%e\n", num); // 输出:1.234568e+04 |
%c | char | 用于输出一个单一字符。 | char grade = 'A'; printf("等级: %c\n", grade); |
%s | char * (字符串) | 用于输出一个字符串。参数应为一个指向字符数组的指针。 | char name[] = "Alice"; printf("你好, %s!\n", name); |
%u | unsigned int | 用于输出一个无符号的十进制整数。 | unsigned int count = 100; printf("数量: %u\n", count); |
%p | void * (指针) | 以十六进制的形式输出一个指针变量所存储的内存地址。 | int num = 10; printf("num的地址是: %p\n", &num); |
%x 或 %X | int | 以小写(%x)或大写(%X)的十六进制形式输出整数。 | int hex_val = 255; printf("十六进制: %x\n", hex_val); |
%o | int | 以八进制形式输出整数。 | int oct_val = 64; printf("八进制: %o\n", oct_val); |
%% | (无) | 用于输出一个百分号 % 本身。 | printf("折扣: 20%%\n"); |
修饰符
除了基本的类型说明,占位符还可以包含修饰符,用来更精确地控制输出的格式,例如对齐方式、宽度、精度等。修饰符放在 % 和类型字符之间,其一般形式为:
%[标志][宽度][.精度][长度]类型
- 宽度
指定输出所占的最小字符数。如果实际输出的字符数小于指定宽度,printf 会用空格进行填充(默认是右对齐)。
- %5d: 输出一个整数,至少占5个字符的宽度。如果整数是 123,输出会是 123(前面有两个空格)。
- %10s: 输出一个字符串,至少占10个字符的宽度。
- 精度
用一个点 . 后跟一个数字来表示。它的含义取决于占位符的类型:
- 对于整数 (d, u, x, o): 指定要输出的最少数字位数。如果实际位数不够,会在前面补0。
- 对于浮点数 (f): 指定小数点后要显示的位数。
- 对于字符串 (s): 指定要输出的最大字符数
- 标志
标志是一些特殊字符,可以改变输出的行为。
-
- (减号): 左对齐。默认是右对齐。
-
- (加号): 强制显示正负号。对于正数,会显示 + 号;对于负数,显示 - 号。
- (空格): 如果是正数,在前面留一个空格;如果是负数,则显示负号。
- 0 (零): 使用前导零来填充宽度,而不是空格(仅当没有使用 - 标志时有效)。
- 长度
长度修饰符用于指定参数的实际数据类型比默认类型更长或更短。
- h: 用于 short int (%hd) 或 unsigned short int (%hu)。
- l (小写L): 用于 long int (%ld) 或 unsigned long int (%lu),以及 double (%lf,虽然在 printf 中 double 和 float 都用 %f,但在 scanf 中必须用 %lf 区分)。
- ll (两个小写L): 用于 long long int (%lld) 或 unsigned long long int (%llu)。
- L: 用于 long double (%Lf)。
一个综合示例:
c
#include <stdio.h>
int main() {
char item[] = "Laptop";
int id = 78;
float price = 1250.75f;
int stock = 5;
printf("=========================================\n");
printf("| %-15s | %-6s | %-10s |\n", "商品", "库存", "价格");
printf("-----------------------------------------\n");
printf("| %-15s | %06d | $%10.2f |\n", item, stock, price);
printf("=========================================\n");
return 0;
}
输出如下:
=========================================
| 商品 | 库存 | 价格 |
-----------------------------------------
| Laptop | 000005 | $ 1250.75 |
=========================================
习题
Makefile文件如下
Makefile
CC=gcc
CFLAGS=-Wall -g
TARGET=ex3
.PHONY: all clean
all:${TARGET}
${TARGET}:${TARGET}.c
${CC} ${CFLAGS} $^ -o $@
clean:
-rm -f ${TARGET} *.o
练习4:Valgrind 介绍
从源码构建可执行程序
本节介绍了一种非常好用的从源码构建可执行文件的步骤:
- 下载源码的归档文件来获得源码
- 解压归档文件,将文件提取到你的电脑上
- 运行./configure来建立构建所需的配置
- 运行make来构建源码,就像之前所做的那样
- 运行sudo make install来将它安装到你的电脑
值得说明的是我使用如下命令获取的源码(教程中给的curl方式我试了不行):
bash
wget https://sourceware.org/pub/valgrind/valgrind-3.25.1.tar.bz2
习题
简单看了一下源码中的configure脚本和Makefile脚本,都是成千上万行的。
练习5:一个C程序的结构
C程序的基本结构
这部分比较简单,就不做笔记了
习题
无
练习6:变量类型
C语言变量类型
这一节我们主要说明一下C语言中的变量类型主要有哪些。
基本变量类型
- 整形:int char long short
用于存储整数。根据存储空间大小和是否有符号,整型可以进一步细分。
类型 | 存储大小 (典型) | 值范围 | 格式化说明符 |
---|---|---|---|
char | 1 字节 | -128 到 127 或 0 到 255 | %c |
unsigned char | 1 字节 | 0 到 255 | %c |
signed char | 1 字节 | -128 到 127 | %c |
short 或 short int | 2 字节 | -32,768 到 32,767 | %hd |
unsigned short | 2 字节 | 0 到 65,535 | %hu |
int | 4 字节 (常见) | -2,147,483,648 到 2,147,483,647 | %d, %i |
unsigned int | 4 字节 (常见) | 0 到 4,294,967,295 | %u |
long 或 long int | 4 或 8 字节 | 取决于系统 | %ld |
unsigned long | 4 或 8 字节 | 取决于系统 | %lu |
long long | 8 字节 | -(2^63) 到 (2^63)-1 | %lld |
unsigned long long | 8 字节 | 0 到 (2^64)-1 | %llu |
注意:
- char 类型在技术上是整型,因为它可以存储-128到127之间的整数,但它通常用来表示ASCII字符。
- signed 和 unsigned 是类型修饰符。signed 表示该变量可以存储正数、负数和零,而 unsigned 表示只能存储非负数。默认情况下,整型(char 除外)都是 signed 的。
- int 的大小依赖于编译器和操作系统,但通常是4字节。
- 浮点型:float double
用于存储带有小数部分的数值。
类型 | 存储大小 (典型) | 精度 | 格式化说明符 |
---|---|---|---|
float | 4 字节 | 大约 6-7 位十进制有效数字 | %f |
double | 8 字节 | 大约 15-16 位十进制有效数字 | %lf |
long double | 通常大于 double | 更高精度 | %Lf |
要点:
- double 提供了比 float更高的精度,因此在科学计算和需要高精度的场合更为常用。
- 在进行浮点数比较时,由于精度问题,直接使用 == 进行比较可能会得到意想不到的结果,通常建议检查两个数的差值是否在一个很小的范围内
- 枚举型:enum
enum 关键字用于定义一个枚举类型,它是一组命名的整型常量。这使得代码更具可读性和可维护性。
示例:
C
enum Weekday { MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY };
enum Weekday today = WEDNESDAY;
默认情况下,枚举列表中的第一个成员值为0,后续成员依次加1。也可以手动为枚举成员指定整数值。
派生类型
- 数组
数组是相同数据类型元素的集合,这些元素在内存中是连续存储的。
- 声明: type arrayName[arraySize];
示例:
C
int numbers[10]; // 声明一个包含10个整数的数组
numbers[0] = 5; // 给第一个元素赋值
- 指针
指针是一种特殊的变量,它存储的是另一个变量的内存地址。
- 声明: type *pointerName;
示例:
C
int var = 20;
int *ip; // 声明一个整型指针
ip = &var; // 将var的地址赋给指针ip
指针是C语言强大功能的核心,广泛用于动态内存分配、函数参数传递和数据结构实现。
- 结构体struct
结构体 (struct) 允许将不同数据类型的变量组合成一个单一的单元。这对于表示真实世界的对象非常有用。
定义与使用:
C
struct Person {
char name[50];
int age;
float salary;
};
struct Person p1;
strcpy(p1.name, "John Doe");
p1.age = 30;
- 联合体union
联合体 (union) 也允许存储不同数据类型的变量,但与结构体不同的是,它的所有成员共享同一块内存空间。这意味着在任何时候,联合体只能存储其中一个成员的值。
定义与使用:
C
union Data {
int i;
float f;
char str[20];
};
union Data data;
data.i = 10;
// 此时 data.f 和 data.str 的值是未定义的
联合体主要用于节省内存,当需要在不同时间存储不同类型的数据时非常有用。
空类型void
void 类型是一个特殊的类型,它表示"没有类型"或"无值"。它的主要用途有:
- 函数返回类型: 当函数不返回任何值时,其返回类型应声明为 void。
C
void printMessage() {
printf("Hello, World!\n");
}
- 函数参数: 当函数不接受任何参数时,可以在括号内使用 void。
C
int getRandomNumber(void);
- 通用指针: void * 是一种通用指针,可以指向任何数据类型的地址。但在使用它之前,必须将其强制转换为具体的指针类型。
C
int x = 10;
void *ptr = &x;
int *int_ptr = (int *)ptr;
习题
c
#include<stdio.h>
int main(int argc, char* argv[]){
char none[] = "";
printf("Here is nothing:%s.\n",none);
int n = 16;
printf("Hex:%x,Dec:%d,Oct:%o\n",n,n,n);
return 0;
}
练习7:更多变量和一些算术
这部分笔记参看第3和第6个练习的笔记。
习题
将数字改得太大会造成溢出,对于符号数来说,一个太大的正数很可能会造成输出为一个负数(溢出)。
练习8:大小和数组
C的字符串
跟着这一题,可以简单聊一聊C的字符串。我们知道C语言中char类型就是int类型,那么C语言中的字符串呢?其实,C的字符串就是char的数组。说白了,C语言中并没有专门的字符串类型,当我们初始化一个字符串时,会发生如下的事情。例如,当我们写 char str[] = "hello"; 时,C语言在内存中创建了这样一块区域:
内存地址 | 0x1000 | 0x1001 | 0x1002 | 0x1003 | 0x1004 | 0x1005 |
---|---|---|---|---|---|---|
内容 | 'h' | 'e' | 'l' | 'l' | 'o' | \0 |
你会发现,虽然 "hello" 只有5个字符,但它实际占用了6个字节的内存空间。最后一个字节存储的就是 \0。\0 是一个值为0的特殊字符,它的唯一使命就是告诉程序:"字符串到此结束。"
C语言中所有处理字符串的标准库函数(定义在 <string.h> 和 <stdio.h> 等头文件中)都依赖于 \0 来确定字符串的边界。这些函数不会预先知道字符串有多长,它们从字符串的起始地址开始,逐个字节地向后扫描,直到遇到第一个 \0 为止。
习题
一些简单的赋值操作
c
#include <stdio.h>
int main(int argc, char *argv[])
{
int areas[] = {10, 12, 13, 14, 20};
char name[] = "Zed";
char full_name[] = {
'Z', 'e', 'd',
' ', 'A', '.', ' ',
'S', 'h', 'a', 'w','\0'
};
// WARNING: On some systems you may have to change the
// %ld in this code to a %u since it will use unsigned ints
printf("The size of an int: %ld\n", sizeof(int));
printf("The size of areas (int[]): %ld\n",
sizeof(areas));
printf("The number of ints in areas: %ld\n",
sizeof(areas) / sizeof(int));
areas[0] = 1;
areas[1] = name[1];
printf("The first area is %d, the 2nd %d.\n",
areas[0], areas[1]);
printf("The size of a char: %ld\n", sizeof(char));
printf("The size of name (char[]): %ld\n",
sizeof(name));
name[1] = 'a';
printf("The number of chars: %ld\n",
sizeof(name) / sizeof(char));
printf("The size of full_name (char[]): %ld\n",
sizeof(full_name));
printf("The number of chars: %ld\n",
sizeof(full_name) / sizeof(char));
printf("name=\"%s\" and full_name=\"%s\"\n",
name, full_name);
return 0;
}
练习9:数组和字符串
数组和指针
这一题引出了C语言中数组个指针的关系问题。首先需要明确,C语言中的数组和指针是不同的东西,但是二者却紧密练习,在很多时候可以互换使用。
二者的紧密联系
- 数组名代表首元素地址
数组名本身就包含了数组的起始地址信息。
C
int a[10];
printf("%p\n", a); // 输出数组 a 的首元素地址
printf("%p\n", &a[0]); // 同样输出数组 a 的首元素地址
在这段代码中,a 和 &a[0] 的值是完全相同的。a 表现得就像一个指向 a[0] 的指针。
- 数组下标 [] 与指针运算 *() 的等价性
C语言的语法设计使得访问数组元素时,下标运算和指针运算在功能上是等价的。
- a[i] 在C语言编译器内部,实际上就是被解释为 *(a + i)。
- a:数组首元素的地址。
- a + i:一个指针运算。根据指针的类型(这里是 int *),地址会向前移动 i * sizeof(int) 个字节,从而指向第 i 个元素(从0开始计数)的地址。
*(a + i):对计算出的地址进行解引用(dereference),获取该地址上存储的值。
因此,以下两种写法是完全等价的:
C
int a[10] = {0, 10, 20, 30, 40};
int i = 2;
// 两种等价的访问方式
printf("Value: %d\n", a[i]); // 输出 20
printf("Value: %d\n", *(a + i)); // 同样输出 20
// 两种等价的获取地址的方式
printf("Address: %p\n", &a[i]); // 输出第2个元素的地址
printf("Address: %p\n", a + i); // 同样输出第2个元素的地址
由于加法满足交换律,*(a + i) 和 *(i + a) 是一样的,所以甚至可以写出 i[a] 这种奇怪但合法的代码,它和 a[i] 的效果完全一样。
- 将数组传递给函数
当你将一个数组作为参数传递给函数时,实际上传递的并不是整个数组的拷贝,而仅仅是该数组首元素的地址。因此,在函数内部,接收这个数组的参数实际上是一个指针。
C
void myFunction(int arr[]) {
// 尽管形参写成数组形式,但编译器会把它当作指针处理
// sizeof(arr) 在这里得到的是指针的大小(如4或8),而不是整个数组的大小
printf("Inside function: sizeof(arr) = %zu\n", sizeof(arr));
}
int main() {
int a[10];
printf("In main: sizeof(a) = %zu\n", sizeof(a)); // 输出 10 * sizeof(int) = 40
myFunction(a); // 输出 4 或 8
return 0;
}
在 myFunction 的声明中,int arr[] 和 int *arr 是完全等价的。这也就是为什么在函数内部无法通过 sizeof 获取数组原始大小的原因,因为它接收到的只是一个指针。
二者的区别
- 内存分配方式不同
- 数组:在定义时,编译器会为其分配一块连续的内存空间,用于存储数组的所有元素。数组名 a 可以看作是这块内存的"别名"或"标签",它与这块内存紧密关联。
- 指针:是一个变量,它只占用足以存储一个内存地址的空间(在32位系统上是4字节,64位系统上是8字节)。这个变量的值是另一个变量的地址。
C
int a[10]; // 分配了 10 * sizeof(int) 的连续空间
int *p; // 只分配了 sizeof(int*) 的空间来存放一个地址
p = a; // 指针 p 的值现在是数组 a 的首地址
- sizeof 运算符的结果不同
这是区分数组和指针最直接的方法。
- sizeof(数组名):返回的是整个数组占用的总字节数。
- sizeof(指针):返回的是指针变量自身占用的字节数(4或8)。
C
int a[10];
int *p = a;
printf("sizeof(a) = %zu\n", sizeof(a)); // 输出 40 (假设 int 是 4 字节)
printf("sizeof(p) = %zu\n", sizeof(p)); // 输出 8 (假设是 64 位系统)
- 数组名是常量,指针是变量
- 数组名:它是一个常量左值 (non-modifiable lvalue)。它代表了数组首元素的地址这个常量,你不能修改数组名本身的值,即不能让它指向其他地方。
- 指针:它是一个变量。你可以随意修改指针的值,让它指向不同的内存地址。
C
int a[10];
int b[10];
int *p;
p = a; // 正确:p 指向 a 的开头
p = b; // 正确:p 现在指向 b 的开头
p++; // 正确:p 指向 a[1]
a = b; // 错误!不能修改数组名 'a' 的值
a++; // 错误!不能修改数组名 'a' 的值
这个区别是根本性的。数组名是其内存块的"身份标识",而指针是存储"地址信息"的容器。你不能改变一个内存块的"身份",但可以改变容器里的内容。
简单来说,它们的关系可以概括为一句话:在大多数表达式中,数组名会被隐式地"降维"为一个指向其首元素的常量指针。
习题
c
#include <stdio.h>
int main(int argc, char *argv[])
{
int numbers[4] = {0};
char name[4] = {'a'};
// first, print them out raw
printf("numbers: %d %d %d %d\n",
numbers[0], numbers[1],
numbers[2], numbers[3]);
printf("name each: %c %c %c %c\n",
name[0], name[1],
name[2], name[3]);
printf("name: %s\n", name);
// setup the numbers
numbers[0] = 'a';
numbers[1] = 2;
numbers[2] = 3;
numbers[3] = 4;
// setup the name
name[0] = 123;
name[1] = 'e';
name[2] = 'd';
name[3] = '\0';
// then print them out initialized
printf("numbers: %d %d %d %d\n",
numbers[0], numbers[1],
numbers[2], numbers[3]);
printf("name each: %c %c %c %c\n",
name[0], name[1],
name[2], name[3]);
// print the name like a string
printf("name: %s\n", name);
// another way to use name
char *another = "Zed";
printf("another: %s\n", another);
printf("another each: %c %c %c %c\n",
another[0], another[1],
another[2], another[3]);
printf("name each: %c %c %c %c\n",
*(name),*(name+1),
*(name+2),*(name+3));
return 0;
}
关于题目"如果一个字符数组占四个字节,一个整数也占4个字节,你可以像整数一样使用整个name吗?你如何用黑魔法实现它?",我的思考就是使用指针操作。即定义一个4字节整数的指针,并将指针指向name,最后利用指针给整数赋值即可。