C/C++ 库文件打包与使用
库文件是将多个 .o 目标文件打包后的产物,供程序在编译链接时使用。根据链接方式的不同,分为静态库(.a)和动态库(.so):
- 静态库:在编译链接时,将库中的代码完整地复制到可执行文件中。生成的可执行文件不再依赖该库。
- 动态库:在编译链接时,只记录库的引用;程序运行时,由动态链接器加载。多个程序可以共享同一份动态库。
静态库
打包静态库
源文件
1 | // myadd.h |
1 | // myadd.cpp |
编译与打包
首先将源文件编译成机器码,再使用 ar 命令打包成静态库。
1 | # 编译成机器码 |
静态库的命名约定为 lib<名称>.a。
使用静态库
1 | // main.cpp |
1 | g++ main.cpp -static -L. -lmyadd && ./a.out |
1 | add(1, 2) = 3 |
-L.:在当前目录./中寻找库文件。-lmyadd:链接名为libmyadd的库。-static:以静态方式链接所有库,生成的可执行文件不再依赖任何外部库。
目录结构
1 | test/ |
ar 命令详解
ar 用于创建和管理静态库。完整语法如下:
1 | ar [-][dmpqrtx][cfosSuvV] [a<成员>] [b<成员>] [i<成员>] [count] [备存文件] [成员文件] |
常用示例
1 | # 将 test.o 加入 libtest.a,已存在则替换;c 创建库,s 建立符号表 |
操作参数
dmpqrtx 为操作参数,只能且必须使用其中一个。以下将备存文件称为库,成员文件称为模块。
d:删除库中指定模块。使用v选项可列出被删除的模块。m:变更库中模块的次序。当多个模块有相同的符号定义时,成员的顺序很重要。默认将指定成员移到最后,可用a/b/i指定位置。p:将库中模块的内容输出到标准输出。使用v选项可在输出前显示模块名。不指定模块名则输出全部。q:快速追加。将新模块追加到库末尾,不检查是否需要替换。a/b/i对此操作无效,模块总是追加到末尾。使用v选项可列出每个模块。追加后符号表未更新,需用ar s或ranlib刷新。r:插入模块(已存在则替换)。当模块名已存在时替换同名模块。默认插入到末尾,可用a/b/i指定位置。t:显示库中的模块清单,一般只显示文件名。x:从库中提取模块。不指定模块名则提取全部。
修饰参数
修饰参数可与操作参数结合使用。
a<成员>:在已存在的成员之后插入新文件。b<成员>:在已存在的成员之前插入新文件。i<成员>:等同于b,在已存在的成员之前插入新文件。c:创建库。不论库是否存在都会创建。f:截短过长的文件名,以兼容其他系统的ar实现。N:与count配合使用,指定同名文件提取或删除的个数。o:提取成员时保留原始日期。不指定则标为提取时间。P:文件名匹配时使用全路径。ar创建库时不能使用全路径(不符合 POSIX 标准),但某些工具可以。s:写入或更新库的目标文件索引。等同于ranlib。S:不创建目标文件索引,加快大库的创建速度。u:仅在r操作时有效,只插入比库中同名文件更新的成员。v:显示详细信息。V:显示ar版本。
动态库
创建动态库
源文件
1 | // myadd.h |
1 | // myadd.cpp |
编译
1 | # 一步到位 |
-fPIC:生成位置无关代码(Position Independent Code),使代码可以被加载到任意内存地址。-shared:生成共享对象(即动态库)。
动态库的命名约定为 lib<名称>.so。
隐式链接
隐式链接在编译时完成,使用方式与静态库类似,但代码不会被复制到可执行文件中。
编译链接
1 | // main.cpp |
1 | g++ main.cpp -L. -lmyadd && ./a.out |
1 | ./a.out: error while loading shared libraries: libmyadd.so: cannot open shared object file: No such file or directory |
编译链接成功,但运行时找不到动态库。原因是编译链接期与运行期查找动态库的路径不同:
| 阶段 | 搜索路径 |
|---|---|
| 编译链接期 | -L 指定的路径、LIBRARY_PATH、/usr/lib、/lib |
| 运行期 | LD_LIBRARY_PATH、/etc/ld.so.conf、/usr/lib、/lib |
临时解决:设置 LD_LIBRARY_PATH
1 | export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/path/to/test/ |
1 | add(1, 2) = 3 |
永久解决:设置 RUNPATH
程序的动态节(dynamic section)中可以嵌入库的搜索路径。使用 readelf -d 查看:
1 | readelf -d ./a.out |
动态节中有两个与搜索路径相关的条目:RPATH 和 RUNPATH。动态链接器按以下顺序搜索:
RPATH(已废弃)LD_LIBRARY_PATH/etc/ld.so.confRUNPATH
RPATH 优先级最高且无法被环境变量覆盖,因此已被废弃。推荐使用 RUNPATH。
在链接时通过 -Wl 向链接器传递参数来设置:
1 | # 原命令 |
-Wl:将后续参数传递给链接器。-R:链接器参数,用于设置RUNPATH。$ORIGIN:表示可执行文件所在的目录(而非当前工作目录)。
显式链接
显式链接在运行时通过 dlopen 等函数动态加载库,无需在编译时链接。
源文件
1 | // myadd.cpp |
显式链接需要 extern "C",否则 C++ 的 name mangling 会导致 dlsym 找不到函数。
编译动态库
1 | g++ -fPIC -shared myadd.cpp -o myadd.so |
加载与调用
1 | // main.cpp |
1 | g++ main.cpp && ./a.out |
1 | add(1, 2) = 3 |
C/C++ 编译与链接
编译与链接主要为下述步骤,其中前三个步骤就是广义上的编译。
- 预处理:把一个
.c源文件处理成.i预处理文件 - 编译:把
.i预处理文件进一步处理成.s汇编文件(狭义上的编译) - 汇编:把
.s汇编文件最终处理得到.o机器码文件 - 链接:把多个
.o机器码文件链接成可执行文件
准备源代码
为了演示这个过程,我编写了以下两个文件作为源代码。
1 | // myheader.h |
1 | // main.c |
接下来,我将以此文件为基础进行演示。
编译(广义上的编译)
广义上的编译指的是从高级编程语言的代码到二进制机器码的过程。
预处理
预处理会对 # 开头的预处理语句进行处理。
通过 gcc -E main.c -o main.i 命令将 main.c 预处理成 main.i。
main.i 文件内容如下:
1 | # 0 "main.c" |
可以看得出来 #include 和 #define 这些预处理指令,都已经成功地执行。
#include成功地把myheader.h引入到了main.c中#define的宏定义也完成了文本替换- 源代码中的注释也全部被去掉了
编译(狭义上的编译)
狭义上的编译指的是把预处理文件编译成汇编代码的过程。
通过 gcc -S main.i -o main.s 命令将 main.i 编译成 main.s。
main.s 文件内容如下:
1 | .file "main.c" |
可以看得出来预处理好的文件已经被编译成了汇编代码。
懂一些汇编的话,应该能看出来 movl $1665, -4(%rbp) 这一行汇编代码就是给变量 i 赋值,rbp 寄存器偏移 -4 的位置就是 i 的地址。
汇编
汇编指的是从汇编代码到机器码这个过程,汇编代码和机器码指令是一一对应的。
通过 gcc -c main.s -o main.o 命令将 main.s 汇编成 main.o。
main.o 就是二进制机器码文件,由于汇编代码和机器码一一对应,我们可以从汇编代码知道 main.o 中暴露了 main 这个符号,并且标记了 main 是一个函数。在后续链接的时候,因为 C 语言中 main 函数的特殊性,它将会被处理为整个二进制程序的入口。
如果我们的代码是作为库提供给其他人使用的话,可能就不会有 main 函数,也不会进入下面的链接环节,而是把多个 .o 文件打包成一个文件,这个打包好的文件也就是库文件,根据打包过程的不同可以分为动态库和静态库。
链接
最后我们可以使用 gcc main.o -o main 将编译获得的多个 .o 文件链接成为可执行程序。在本文的例子中,只有一个 .o 文件。
最终 main 就是可执行程序。
目录结构
1 | test/ |
自建 CA 证书并用其签名服务器证书
自建 CA
什么是 CA 证书?
CA(Certificate Authority,证书颁发机构) 是一个”可信第三方”。
它用自己的私钥给别人的证书”盖章”(签名),从而证明”这张证书确实是某某机构发的”。
- 根 CA 证书(Root CA):自签名的、最顶层的证书,是信任链的起点。
它自己给自己签名,所以也叫”自签名证书”。 - 服务器证书(Server Certificate):由 CA 签发的、真正用于 HTTPS 的证书。
浏览器验证流程:服务器证书 ← 由根 CA 签名,而根 CA 在客户端的信任库里,于是信任成立。
密钥对(Key Pair) 的概念:
| 对象 | 作用 | 是否保密 |
|---|---|---|
私钥(.key) |
签名、解密 | 必须严格保密,永不外传 |
公钥/证书(.crt) |
验证签名、加密 | 公开分发 |
私钥一旦泄露,等于你的 CA 被”盗号”,任何人可以伪造合法证书。
依赖
1 | # 检查 openssl 是否安装 |
生成根 CA 私钥
1 | # -out 输出文件名 |
编写 CA 配置文件
1 | # KeunlasRootCA.cnf |
生成根 CA 自签名证书
1 | # -new 生成新证书请求 |
校验根 CA 证书
1 | # 查看证书内容 |
签发服务器证书
生成服务器私钥和 CSR
1 | # 服务器私钥 |
CSR 里包含服务器的公钥和身份信息(CN=localhost),
CA 拿到 CSR 后核验、签名,就能”盖章”发证。
CN=localhost 表示这张证书是给 localhost 域名用的。
使用 CA 签发服务器证书
1 | # -CA / -CAkey 用哪个 CA 及其私钥来签名 |
为什么必须加 SAN?
现代浏览器(Chrome 58+、Firefox)只认 SAN,不再认 CN。
没有 SAN 的证书,浏览器会报 ERR_CERT_COMMON_NAME_INVALID。
SAN 必须包含你要访问的实际域名/IP,例如 DNS:localhost、IP:127.0.0.1。
校验服务器证书
1 | # 确认签发者、SAN 是否正确 |
Diffie-Hellman(DH)密钥交换参数
过去常用于 TLS 握手时的前向保密(Perfect Forward Secrecy, PFS)
1 | openssl dhparam -out dh4096.pem 4096 |
曾用于 TLS 1.2 时代的 DHE 密码套件,实现前向保密。TLS 1.3 / ECDHE 已取代它,一般不再需要。
正则表达式分组子表达式
基于 ECMAScript 正则表达式语法
一对括号就是一个分组。\1 引用第一个分组所匹配到的内容,\2、\3 等同理。
示例:(a+)b\1 匹配 aabacaabaa
可匹配到两处:aba 和 aabaa。
第一次匹配
- 位置 0:
(a+)贪婪匹配aa(位置 0–1),b匹配位置 2 成功,\1需要aa但位置 3–4 是ac,失败。回溯(a+)为a(位置 0),b在位置 1 是a,失败。 - 位置 1:
(a+)匹配a(位置 1),b匹配位置 2 成功,\1需要a,位置 3 是a,匹配成功。 - 匹配结果为
aba(位置 1–3),分组 1 捕获a。
第二次匹配
- 位置 4:字符
c不符合a+,跳过。 - 位置 5:
(a+)匹配aa(位置 5–6),b匹配位置 7 成功,\1需要aa,位置 8–9 是aa,匹配成功。 - 匹配结果为
aabaa(位置 5–9),分组 1 捕获aa。
正则表达式前瞻匹配(零宽度先行断言)
基于 ECMAScript 正则表达式语法
断言不消耗输入中的任何字符,仅判断当前位置是否满足某种条件。例如 ^ 和 $ 分别断言文本的开头和结尾,本身不匹配任何字符。
零宽度正向先行断言
(?=...):当前位置之后的文本能够匹配...时,断言成功。
示例一:(?=(a+))a*b 匹配 baaba
- 位置 0(
b):a+无法匹配,失败。 - 位置 1(
a):a+贪婪匹配aa(位置 1–2),断言成功,当前位置仍在 1。继续匹配a*b:a*贪婪匹配aa(位置 1–2),b匹配位置 3 成功。 - 最终匹配
aab(位置 1–3),分组 1 捕获aa。
示例二:(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9]).{6,} 匹配 abc2ABC
要求包含小写字母、大写字母和数字,且长度至少为 6。依次执行三个前瞻断言(均从位置 0 开始):
(?=.*[a-z]) — 是否包含小写字母
.*贪婪匹配全部 7 字符,[a-z]无剩余可匹配,失败。- 回溯至 6 字符:剩
C,[a-z]失败。 - 回溯至 5 字符:剩
B,失败。 - 回溯至 4 字符:剩
A,失败。 - 回溯至 3 字符:剩
2,失败。 - 回溯至 2 字符:剩
c,[a-z]匹配成功。
(?=.*[A-Z]) — 是否包含大写字母
.*贪婪匹配全部 7 字符,[A-Z]失败。- 回溯至 6 字符:剩
C,[A-Z]匹配成功。
(?=.*[0-9]) — 是否包含数字
.*贪婪匹配全部 7 字符,[0-9]失败。- 回溯至 6 字符:剩
C,失败。 - 回溯至 5 字符:剩
B,失败。 - 回溯至 4 字符:剩
A,失败。 - 回溯至 3 字符:剩
2,[0-9]匹配成功。
三个断言均成功,当前位置仍在位置 0。最后 .{6,} 匹配全部 7 个字符,匹配结果为 abc2ABC。
零宽度负向先行断言
(?!...):当前位置之后的文本不能匹配...时,断言成功。
与正向先行断言相反,当 ... 在当前位置匹配失败时,负向断言才算成功。
《SQLite3》面向对象包装 SQLite3 C/C++ 基础接口
线程安全问题
SQLite3 在编译的时候可以通过添加定义来控制整个库的线程安全问题。
当 SQLITE_THREADSAFE 这个宏为 0 时,整个库是线程不安全的。
当 SQLITE_THREADSAFE 这个宏为 1 时,整个库是线程安全的。
当 SQLITE_THREADSAFE 这个宏为 2 时,整个库是线程安全的,但是同一个 sqlite3* db 不能被多个线程同时使用。
当编译的时候 SQLITE_THREADSAFE 为 1 或者 2 时,可以通过 sqlite3_config() 这个接口来更改线程安全模式:
SQLITE_CONFIG_SINGLETHREAD单线程SQLITE_CONFIG_MULTITHREAD多线程SQLITE_CONFIG_SERIALIZED串行化
面向对象类
1 | #include <functional> |
我设置了两个成员变量,一个是数据库连接,另一个是打开的数据库名称。
1 | std::string db_name_; |
数据库的开启
首先是类的构造函数,主要是打开数据库连接,并在出错时抛出异常信息。
1 | SQLite3::SQLite3(const std::string_view& db_name) : db_name_(db_name) { |
数据库的关闭
然后是析构和关闭数据库连接。
析构函数因为不能抛出异常,所以我使用了 sqlite3_close_v2 来关闭连接。
主动 close 则是使用 sqlite3_close 接口,并返回错误代码。
1 | SQLite3::~SQLite3() { |
执行 SQL 语句
无回调函数
无回调函数的操作,只需要执行 SQL 语句,并抛出或者处理执行失败的情况即可。
1 | void SQLite3::exec(const std::string_view& sql) { |
有回调函数
当执行一些带有结果的 SQL 语句时,就需要回调函数去处理结果。
我定义了两个类型用来封装 sqlite3_exec 中的回调函数。
ExecCallback 对象的返回值为 bool 类型,用来指示回调函数是否成功执行。
并且 ExecCallback 对象拥有一个参数 TableRow,这是一个哈希表,存储该行的每一个列数据的 name 和 value 的键值对。
1 | using TableRow = std::unordered_map<std::string, std::string>; |
我通过 void* context 传递 ExecCallback 对象。
当回调函数触发时,填充 TableRow row 对象的数据,并以此为参数调用 ExecCallback 对象。
1 | void SQLite3::exec(const std::string_view& sql, ExecCallback callback) { |
测试一下这个类
1 | int main() { |
运行上述 main 函数后,获得输出如下:
1 | User ID: 1, Name: Alice |
再次运行该代码,输出如下:
1 | Failed to execute SQL: UNIQUE constraint failed: users.id |
测试完毕,这样一个初步的面向对象封装就完成了。
《SQLite3》SQLite3 C/C++ 基础接口
介绍
最基本的使用 sqlite3 的 c api 还是挺简单的。只需要记住三个接口即可。
打开数据库:
sqlite3_open()关闭数据库:
sqlite3_close()执行 SQL 语句:
sqlite3_exec()
开启数据库
先来看看如何开启数据库,我们需要提供两个参数,数据库名称和一个 sqlite3 指针的指针。
1 | int sqlite3_open( |
具体的使用基本如下:
1 | char* db_name = "test.db"; |
SQLite3 还提供了可以更加精细的控制打开数据库方式的接口:sqlite3_open_v2()
1 | int sqlite3_open_v2( |
可以看到多了两个参数,可以控制数据库只读或其他更精细的操作。sqlite3_open() 可以看作是下述特定参数 sqlite3_open_v2()。
1 | char* db_name = "test.db"; |
具体的 sqlite3_open_v2() 使用可以参考官方文档 Opening A New Database Connection.
关闭数据库
关闭数据库只需要把 sqlite3* db 作为参数传入即可。使用十分简单。
就像 open 接口有多个版本一样,close 接口也有多个版本。
1 | int sqlite3_close(sqlite3*); |
主要区别是,当 db 还有某些未完成的任务时,两个接口会有不同的行为。
sqlite3_close 会返回 SQLITE_BUSY。
sqlite3_close_v2 会返回 SQLITE_OK。但不会立即释放数据库连接,而是将数据库连接标记为不可用的“僵尸”状态,并安排在所有预处理语句完成、所有 BLOB 句柄关闭以及所有备份操作结束后自动释放数据库连接。
执行 SQL 语句
1 | int sqlite3_exec( |
db 是打开的数据库连接。
sql 是要执行的 SQL 语句。
callback 是执行完 SQL 语句后进行的回调函数。(可选参数,不需要可传递空指针)
data 是传给 callback 的第一个参数,可用于传递上下文。(可选参数,不需要可传递空指针)
errmsg 会返回相应的错误信息,如果无错误则被设为空指针。(可选参数,不需要可传递空指针)
回调函数
1 | int callback(void* context, int col_size, char** col_values, char** col_names); |
只要理解了这个回调函数会对结果的每一行数据执行一次,我们就可以轻松的理解这个回调的后面三个和列相关的参数。
context:从 sqlite3_exec() 传递过来的用户数据col_size:结果行的列数col_values:列值的字符串数组col_names:列名的字符串数组
返回值为 0 时,继续查询。
返回值为 1 时,终止查询。
《SDL3》创建第一个窗口
渲染第一个 SDL 窗口
首先我们从一个简单的例子讲起,我们渲染一个绿色背景的窗口。
基本步骤就是创建窗口和渲染器,然后在循环中不断的渲染窗口并展示。
SDL_Init,SDL_CreateWindow 和 SDL_CreateRenderer 拥有 bool 类型的返回值,下列代码中省略了返回值判断。
1 | #include <SDL3/SDL.h> |
更加的模块化 SDL3
SDL3 中只需要 #define SDL_MAIN_USE_CALLBACKS 1 并 #include <SDL3/SDL_main.h> 即可使用回调函数代替 main 函数,从而更加方便的编写程序。
最关键的四个回调函数为:
- 程序启动时执行:
SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[]); - 有新事件时执行:
SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event); - 程序每帧都执行:
SDL_AppResult SDL_AppIterate(void *appstate); - 程序退出时执行:
void SDL_AppQuit(void *appstate, SDL_AppResult result);
具体代码结构如下:
1 | #define SDL_MAIN_USE_CALLBACKS 1 // 必须设置这个宏 |
白名单模式的 gitignore 配置
WhiteLists
虽然已经用了多年的 git, 但是至今都没怎么好好的总结一下 ignore 规则。今天心血来潮,重新仔细的总结一下 gitignore 到底怎么好好的去写,并记录一下 gitignore 的白名单写法。
基础规则
首先是 gitignore 的基础规则。基础规则的总结,我参考了这篇文章 .gitignore File – How to Ignore Files and Folders in Git. 不过参考文章中有些操作在我所使用的 Archlinux 中无法复现, 所以下面的总结以我自己的实际操作为基础。
如何在 Git 中忽略一个文件或文件夹
如何忽略一个文件或目录
1 | /text.txt # 忽略根目录下 text.txt 文件 |
如何不忽略一个文件或目录
加入我们忽略了所有md文件, 但唯独不忽略README.md的情况, 如下所示.
1 | *.md # 忽略所有名字结尾是 .md 的文件和目录 |
无法排除已经被忽略的目录内的一个文件
1 | test/ # 忽略所有名字带有 test 的目录 |
如何忽略以前提交的文件
假如你不小心把 .build 文件提交了, 现在想去除这个文件.过程如下所示.
1 |
|
白名单模式
一般来说 gitignore 是使用黑名单模式,也就是写在 ignore 中的项目会被忽略,但是有些时候使用黑名单模式会有一些疏漏,当项目逐渐庞大起来,难免会有一些遗漏,将一些临时的文件和不希望添加入 git 管理的文件加入 git 中。这个时候白名单模式的 gitignore 能够实现更加精细的管理。
假设我们有以下结构的 C++ 项目需要管理:
1 | your-project-dir |
首先忽略全部文件,并取消忽略目录
白名单模式的第一步。这里取消忽略目录,是因为被忽略目录下的文件是必定被忽略的,为了能够指定不忽略的文件,需要取消忽略目录。
1 | * |
忽略不想要的目录 (可选)
因为 build 目录完全是一个临时目录,我不想提交里面的任何文件,所以可以直接忽略掉。如果这个目录里有某些特定的文件需要提交,则不应该忽略这个目录。
之所以这是一个可选的操作,是因为在上一步忽略了所有文件之后,所有的目录内都没有需要提交的文件,在 git 中这样的“空目录”不会被提交。
1 | /build/ |
取消忽略特定的文件
有些文件我们希望必须提交的,我们可以直接取消忽略这些文件。
1 | !.gitignore |
这样之后,任何目录下的 .gitignore 和 README.md 文件都会被提交上去。
取消忽略特定目录下的全部文件
我希望提交 docs 目录下的所有文件,包括其子目录下的所有文件。通过下面这种写法,可以取消忽略 docs 目录下的所有文件,并递归的忽略其子目录下的所有文件。
1 | !/docs/**/* |
取消忽略特定目录下的特定文件
有的目录我只希望提交特定类型的文件,例如 src 目录下的 .cpp 代码文件和 include 目录下的 .h 头文件。
下面这种写法取消忽略了对应目录下所有特定后缀的文件,并递归的忽略其子目录下所有特定后缀的文件。
1 | !/include/be/**/*.h |
总结
最后的白名单 gitignore 文件看上去是这样的:
1 | * |
在比较复杂的项目中,或者需要精细控制提交的文件时,白名单模式的 ignore 文件是真的很好用。