ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

libcurl库从API使用到源码编译与封装实践

libcurl库从API使用到源码编译与封装实践 1. libcurl到底是什么为什么大家都在用先说结论libcurl是一个跨平台、支持多种协议的网络传输库C语言写的老牌开源项目几乎所有你能想到的现代编程语言里的HTTP客户端底层要么直接用它要么参考了它的设计思路。PHP里的cURL扩展、Python的pycurl、Node.js的curl绑定本质上都是在libcurl外面套了一层壳。我做C/C后端开发这些年涉及文件上传下载、接口调用、音视频拉流这些场景第一反应基本都是libcurl。原因很简单它把HTTP、HTTPS、FTP、SFTP、SMTP、RTSP这些协议全部收敛到同一套API里你只需把URL传进去选择要用的协议剩下的事情库内部帮你处理。这意味着你不需要为了一个FTP下载功能单独研究FTP协议栈也不需要为了RTSP拉流再引一个重量级多媒体框架。那“libcurl库的制作”是什么意思其实就是指编译libcurl源码生成静态库.a或.lib或动态库.so或.dll的过程。很多人直接用系统包管理器装现成的库比如apt install libcurl4-openssl-dev但在嵌入式环境、Windows交叉编译、或者需要定制协议和SSL后端的时候你就必须手动编译。而且手动编译一次你对这个库的内部依赖、构建选项、链接方式这些理解会比单纯调用API深得多。这篇博文我会分两部分讲先讲libcurl的API使用细节附带我能直接上手的代码示例再讲libcurl的库制作过程包括Linux和Windows下的编译选项、静态库与动态库的选择、交叉编译的注意事项。最后把我实际踩过的坑整理成速查表。2. API使用的核心套路七步走libcurl的使用套路非常固定官方文档称之为Easy Interface整个生命周期可以拆成七个步骤调用curl_global_init()做全局初始化调用curl_easy_init()创建easy handle用curl_easy_setopt()设置传输参数比如URL、回调函数、超时时间调用curl_easy_perform()执行同步传输用curl_easy_getinfo()获取传输结果信息调用curl_easy_cleanup()释放easy handle程序退出前调用curl_global_cleanup()清理全局资源这七步里第3步是最核心也是最容易出问题的curl_easy_setopt()有上百个选项不可能全部背下来但常用的就那么十几个。我先说清楚这几个关键选项的语义后面给示例的时候你会看得更明白。2.1 初始化与清理的细节curl_global_init()在整个进程生命周期里只需要调用一次而且不是线程安全的。如果你的程序是多线程架构必须在创建任何线程之前调用它或者用curl_global_init_mem()这种变体来做定制化内存管理。很多线上崩溃问题排查到最后其实是某些后台线程里动态调用了curl_easy_init()而全局初始化还没完成。curl_global_init()的返回值是CURLcode枚举只有CURLE_OK才说明初始化成功。这个检查不能省因为该函数会申请全局资源失败情况下后面的curl_easy_init()大概率也会失败。清理阶段有个高频错误很多人只记得curl_easy_cleanup()忘了curl_global_cleanup()。虽然多数操作系统在进程退出时会回收内存但如果你是用Valgrind跑内存泄漏检测漏掉这个调用会直接报still reachable的告警干扰你定位真正的泄漏点。2.2 CURLoption选项带参类型curl_easy_setopt()的第二个参数是CURLoption枚举第三个参数类型根据选项不同分别是long、char*、void*、curl_off_t。用错类型编译器不会报错因为第三个参数是可变参数但运行时会得到莫名其妙的行为。我列几个高频选项的实际类型CURLOPT_URLconst char*完整的URL字符串必须包含协议前缀比如http://或https://CURLOPT_WRITEFUNCTION函数指针类型是size_t (*)(char *ptr, size_t size, size_t nmemb, void *userdata)CURLOPT_WRITEDATAvoid*传给上面回调的userdataCURLOPT_TIMEOUTlong整个请求的最大秒数CURLOPT_CONNECTTIMEOUTlong连接建立的最大秒数CURLOPT_POSTFIELDSconst char*POST请求的body数据CURLOPT_POSTFIELDSIZElongbody数据的字节长度CURLOPT_FOLLOWLOCATIONlong非0表示自动跟随重定向CURLOPT_SSL_VERIFYPEERlong非0表示校验服务器证书生产环境必须为1CURLOPT_SSL_VERIFYHOSTlong非0表示校验主机名生产环境必须为2一个很容易翻车的点CURLOPT_TIMEOUT接收的是long类型如果你传的是浮点数比如3.0C语言会隐式转成3倒不会出错但如果你传的是char*比如写成了CURLOPT_TIMEOUT, 3类型不匹配在运行时就直接行为未定义。这类错误在编译期完全看不出来排查起来特别费劲。3. 实操示例一带进度回调的文件下载我先写一个实际项目里最常见的用法——从HTTP服务器下载文件到本地带进度显示。这个场景涉及CURLOPT_WRITEFUNCTION和CURLOPT_XFERINFOFUNCTION两个回调是理解libcurl数据流向的关键。#include stdio.h #include stdlib.h #include curl/curl.h struct FileInfo { FILE *fp; long expected_size; }; static size_t write_cb(char *ptr, size_t size, size_t nmemb, void *userdata) { struct FileInfo *info (struct FileInfo *)userdata; size_t written fwrite(ptr, size, nmemb, info-fp); return written; } static int progress_cb(void *clientp, curl_off_t dltotal, curl_off_t dlnow, curl_off_t ultotal, curl_off_t ulnow) { struct FileInfo *info (struct FileInfo *)clientp; if (dltotal 0) { int percent (int)(dlnow * 100 / dltotal); printf(\r下载进度: %d%% (%lld/%lld字节), percent, dlnow, dltotal); fflush(stdout); } return 0; } int download_file(const char *url, const char *output_path) { CURL *curl NULL; CURLcode res CURLE_OK; struct FileInfo info {0}; info.fp fopen(output_path, wb); if (!info.fp) { fprintf(stderr, 无法打开输出文件: %s\n, output_path); return -1; } curl_global_init(CURL_GLOBAL_DEFAULT); curl curl_easy_init(); if (!curl) { fclose(info.fp); curl_global_cleanup(); return -1; } curl_easy_setopt(curl, CURLOPT_URL, url); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb); curl_easy_setopt(curl, CURLOPT_WRITEDATA, info); curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, progress_cb); curl_easy_setopt(curl, CURLOPT_XFERINFODATA, info); curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 60L); curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L); curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); res curl_easy_perform(curl); if (res ! CURLE_OK) { fprintf(stderr, 下载失败: %s\n, curl_easy_strerror(res)); } else { printf(\n下载完成\n); } curl_easy_cleanup(curl); fclose(info.fp); curl_global_cleanup(); return (res CURLE_OK) ? 0 : -1; }3.1 回调函数的设计要点write_cb是libcurl把收到的数据交给调用方的出口。这里有个重要约定size_t * nmemb代表数据块大小乘以块个数本质是告知你当前缓冲区有多少字节你要把这个函数里处理的数据字节数原样返回。如果返回值小于实际收到的字节数libcurl会认为传输出错并终止请求返回CURLE_WRITE_ERROR。所以fwrite的返回值可以直接透传给调用方但要小心很多文件系统写入是带缓冲的fwrite返回的字节数通常等于你传入的字节数可一旦磁盘写满或者权限出问题返回值和期望值就会不匹配。这里建议逐字节核对简单做法是if (written ! size * nmemb) return 0;强制触发libcurl的错误处理路径。进程回调progress_cb的返回类型是int返回非零值会中止传输。这一点可以用来实现用户取消下载的功能。在我实际做的下载器项目里前端会设置一个全局原子变量进度回调里检查到取消标记就返回1从而优雅中断下载流程。3.2 关于重定向、超时和SSL的取舍CURLOPT_FOLLOWLOCATION用于自动跟随重定向但默认最多跳转5次CURLOPT_MAXREDIRS可以调整这个上限。重定向场景下有个隐藏坑如果重定向跳转到不同域名libcurl默认不会把Authorization头带过去这是为了安全但如果你确实需要跨域携带认证信息要手动设置CURLOPT_UNRESTRICTED_AUTH。关于HTTPS证书校验CURLOPT_SSL_VERIFYPEER和CURLOPT_SSL_VERIFYHOST在生产环境一定不要关。很多新手图省事直接设置为0这在开发环境没问题一旦上生产就是重大安全隐患。如果你的服务器用的是私有CA签发的证书正确做法是设置CURLOPT_CAINFO指向CA证书文件或者把根证书加入系统信任链。超时时间这里我设置了连接超时10秒和总超时60秒。大文件下载场景下总超时60秒可能不够要根据具体业务调整或者使用CURLOPT_LOW_SPEED_LIMIT和CURLOPT_LOW_SPEED_TIME组合来做“速度低于阈值持续N秒则断开”的控制这比固定总超时更符合真实大文件下载场景。4. 实操示例二JSON POST请求与响应解析下载只是libcurl的初级用法实际开发里更常见的是调用HTTP API发JSON数据、拿JSON响应。下面这个例子演示了如何发送POST请求并把响应存入内存字符串。#include stdio.h #include stdlib.h #include string.h #include curl/curl.h struct MemBuf { char *data; size_t len; }; static size_t recv_cb(char *ptr, size_t size, size_t nmemb, void *userdata) { struct MemBuf *buf (struct MemBuf *)userdata; size_t bytes size * nmemb; char *tmp realloc(buf-data, buf-len bytes 1); if (!tmp) { return 0; } buf-data tmp; memcpy(buf-data buf-len, ptr, bytes); buf-len bytes; buf-data[buf-len] \0; return bytes; } int post_json(const char *url, const char *json_body, char **response) { CURL *curl NULL; CURLcode res CURLE_OK; struct MemBuf buf {0}; struct curl_slist *headers NULL; curl_global_init(CURL_GLOBAL_DEFAULT); curl curl_easy_init(); if (!curl) { return -1; } headers curl_slist_append(headers, Content-Type: application/json); headers curl_slist_append(headers, Accept: application/json); curl_easy_setopt(curl, CURLOPT_URL, url); curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); curl_easy_setopt(curl, CURLOPT_POST, 1L); curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json_body); curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, (long)strlen(json_body)); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, recv_cb); curl_easy_setopt(curl, CURLOPT_WRITEDATA, buf); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); res curl_easy_perform(curl); if (res ! CURLE_OK) { fprintf(stderr, POST失败: %s\n, curl_easy_strerror(res)); *response NULL; } else { long http_code 0; curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, http_code); printf(HTTP状态码: %ld\n, http_code); *response buf.data ? buf.data : strdup(); } curl_slist_free_all(headers); curl_easy_cleanup(curl); curl_global_cleanup(); return (res CURLE_OK) ? 0 : -1; }4.1 响应内存管理的坑recv_cb里用realloc动态追加数据这是内存管理的高危区。libcurl的回调可能在极短时间内被调用很多次每次回调都重新分配内存如果分配失败要返回0libcurl会立刻中断传输。另外别忘了在buf.data末尾手动加\0因为libcurl返回的数据不保证以空字符结尾当你把buf.data当字符串使用时就会读到脏数据。realloc成功后会释放原指针所以tmp赋值给buf-data前不要先释放buf-data这是常见的二次释放错误。我习惯是char *tmp realloc(buf-data, ...); if (!tmp) return 0; buf-data tmp;这样即使realloc失败原数据也还在可以继续使用。4.2 设置POSTFIELDS时的几个隐藏选项CURLOPT_POSTFIELDS被设置后libcurl会自动把请求方法改为POST这时候其实不需要显式设置CURLOPT_POST。但如果你希望发送的是PUT、DELETE等其它方法要用CURLOPT_CUSTOMREQUEST来覆盖。CURLOPT_POSTFIELDSIZE在高并发大JSON场景下尽量显式设置。不设置时libcurl内部会对数据调用strlen()来计算长度这本身没问题但如果你的body包含\0字符长度就算错了服务端收到的数据会被截断。另外如果CURLOPT_POSTFIELDS指向的是栈上分配的临时变量需要在curl_easy_perform()完成前保证它有效。libcurl内部对CURLOPT_POSTFIELDS是直接引用而不是拷贝这是个容易踩但官方文档明确说明过的行为。5. 深入curl_easy_getinfo能拿什么每次传输完成后curl_easy_getinfo()可以从easy handle里提取丰富的统计信息。这些信息在日志采集、性能分析、故障定位中非常有用。我常用这几个CURLINFO_RESPONSE_CODEHTTP响应码longCURLINFO_TOTAL_TIME整个请求总耗时double秒CURLINFO_NAMELOOKUP_TIMEDNS解析耗时double秒CURLINFO_CONNECT_TIMETCP连接建立耗时double秒CURLINFO_APPCONNECT_TIMEapplication层握手完成耗时比如TLS握手double秒CURLINFO_SIZE_DOWNLOAD下载的字节数curl_off_tCURLINFO_SPEED_DOWNLOAD平均下载速度curl_off_tbytes/secCURLINFO_EFFECTIVE_URL实际请求的URL重定向后能看到最终地址double dns_time, connect_time, total_time; long http_code; curl_easy_getinfo(curl, CURLINFO_NAMELOOKUP_TIME, dns_time); curl_easy_getinfo(curl, CURLINFO_CONNECT_TIME, connect_time); curl_easy_getinfo(curl, CURLINFO_TOTAL_TIME, total_time); curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, http_code); printf(DNS: %.3fs, TCP连接: %.3fs, 总计: %.3fs, HTTP: %ld\n, dns_time, connect_time, total_time, http_code);这些耗时数据在定位接口慢的问题时特别有用。比如DNS解析时间过长就要考虑是不是本机DNS配置有问题或者要引入DNS缓存如果TCP连接时间大可能要考虑连接池复用也就是libcurl的Multi Interface或者easy handle复用机制。6. libcurl库的制作源码编译完整流程工具选型和接口使用讲完了现在正式进入“libcurl库的制作”这个主题。这里我说的“制作”是指把curl源码编译成你项目里能链接的库文件。6.1 获取源码与目录结构官方源码存放在GitHub仓库curl/curl也可以用官方发布的压缩包。解压后目录里几个关键位置lib/libcurl库核心源码我们要编译的就是这个目录src/curl命令行工具源码依赖libcurlinclude/头文件编译后要拷到系统include目录docs/文档包括libcurl/子目录下所有API的详细说明tests/测试代码编译库后跑测试会用一个容易忽略的事libcurl还依赖openssl、zlib、libssh2等第三方库。你编译libcurl时可以选择启用或禁用这些依赖这就是为什么编译出来的库大小差异很大。6.2 Linux下的configure构建Linux下最标准的做法是./configure make make install。关键在于configure的选项。基础编译命令./configure --prefix/usr/local/curl \ --with-openssl \ --with-zlib \ --enable-static \ --enable-shared \ --disable-ldap \ --disable-ldaps make -j$(nproc) make install--prefix指定安装目录--with-openssl启用HTTPS支持--enable-static生成静态库--enable-shared生成动态库。--disable-ldap和--disable-ldaps是关闭几乎没人用的LDAP协议能减少体积。如果configure时报找不到openssl头文件你还需要指定CFLAGS-I/path/to/openssl/include LDFLAGS-L/path/to/openssl/lib。验证编译是否成功用curl-config/usr/local/curl/bin/curl-config --version /usr/local/curl/bin/curl-config --cflags /usr/local/curl/bin/curl-config --libscurl-config --libs会输出完整的链接参数通常是-L/usr/local/curl/lib -lcurl。如果你还链接了openssl和zlib这个命令也会一并输出省得自己手写。6.3 Windows和交叉编译在Windows上用CMake构建cmake -B build -G Visual Studio 17 2022 -A x64 \ -DCMAKE_USE_OPENSSLON \ -DBUILD_SHARED_LIBSOFF \ -DCURL_USE_SCHANNELON cmake --build build --config ReleaseCURL_USE_SCHANNELON表示使用Windows原生的SSL后端这样就不需要额外编译openssl适合Windows平台。如果你有MinGW环境也可以用configure脚本基本流程和Linux一致。交叉编译时比如编译ARM平台的库要设置工具链./configure --prefix/opt/arm-linux/curl \ --hostarm-linux-gnueabihf \ --with-openssl/opt/arm-linux/openssl \ --enable-static \ --disable-shared make make install--host指定目标平台--with-openssl指向交叉编译好的openssl安装目录。交叉编译最磨人的地方就是依赖库也要交叉编译一遍而且版本必须匹配openssl的A/B兼容性是出了名的严格。6.4 静态库和动态库怎么选这个问题我在项目里反复权衡过很多次。静态库适合嵌入式、工具型软件、分发给第三方运行的产品好处是部署时不需要考虑目标机器上有没有对应so坏处是二进制体积大、openssl升级后要重新编译整个程序。动态库适合跑在服务器上的长期服务多个程序可以共享一份so占用内存少更新SSL时可以只换so不重新编译业务代码。需要特别注意的是静态库和openssl的静态库混用时-lcurl的链接顺序要放在第三方库前面因为静态库是按需抽取的依赖方向是从左到右的。写成-L/usr/local/curl/lib -lcurl -lssl -lcrypto -lz通常都没问题但如果你把-lcurl放最后链接器可能找不到curl里的符号引用。6.5 标题里的制作可能还指封装库“libcurl库的制作”如果从项目角度理解还有一种可能是你想基于libcurl二次封装一个自己的网络库。这种做法在实际项目里很常见因为curl_easy_setopt()的参数太灵活团队里不是每个人都清楚应该设置什么封装一层可以把复杂度和安全策略收敛在一起。我封装过的一个例子对内只暴露一个结构体和三个函数typedef struct { const char *url; const char *post_data; const char *token; int timeout_sec; } HttpRequest; typedef struct { int http_code; char *body; size_t body_len; double total_time; } HttpResponse; int http_client_init(void); int http_client_send(const HttpRequest *req, HttpResponse *resp); void http_client_cleanup(void);封装层内部把CURLOPT_SSL_VERIFYPEER、CURLOPT_SSL_VERIFYHOST、CURLOPT_TIMEOUT这些安全参数统一设死业务层只能用我暴露的字段这样既简化了调用也杜绝了有人误把证书校验关掉的风险。如果你在写一个服务的网络通信模块强烈建议做一层薄封装没必要让业务代码直接面对上百个option。7. 常见问题与排查技巧实录7.1 链接时报undefined reference这是做库制作时最常遇到的问题。出错信息形如undefined reference to curl_easy_init原因基本有两种一是没链接-lcurl二是链接顺序不对。检查方法ldconfig -p | grep curl确认系统里有curl库后在编译命令里加上-lcurl。如果configure时指定了--prefix还需要加-L/usr/local/curl/lib。链接顺序的问题前面已经说过把-lcurl尽量往前放。7.2 回调函数引发崩溃错误写法是// 错误示范 curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb); curl_easy_setopt(curl, CURLOPT_WRITEDATA, NULL);回调函数里没有判空就直接解引用userdata一旦回调触发就会段错误。我的习惯是回调函数内部的userdata必须做好判空宁可多写两行防御代码也别让线上崩溃。另外回调函数必须符合正确的函数签名参数个数或类型不对编译能过但运行时栈会乱掉这种bug极难排查。7.3 进度回调不触发CURLOPT_NOPROGRESS默认值是1也就是默认不启用进度回调。你要展示进度必须显式设置CURLOPT_NOPROGRESS, 0L。这个我每次都会忘写在这里提醒自己也提醒你。7.4 HTTPS证书校验失败curl: (60) SSL certificate problem: unable to get local issuer certificate这个广泛存在于内网环境。用私有CA签发的证书或者库内部默认的CA路径不对。解决方案是设置CURLOPT_CAINFO指向本地的CA证书文件或者设置CURLOPT_CAPATH指向CA证书目录实在没有CA证书且是内部测试环境再考虑把CURLOPT_SSL_VERIFYPEER和CURLOPT_SSL_VERIFYHOST设为0但必须加明确注释说明原因和有效期7.5 多线程环境下的崩溃libcurl的Easy Interface单接口不是线程安全的我早期用的时候把同一个easy handle丢到多线程里去执行结果就是各种崩溃和内存损坏。多线程并发时正确做法是每个线程创建自己的easy handle而全局初始化curl_global_init()只在主线程初始化一次。libcurl官方还提供了Multi Interface专门用于多连接非阻塞管理但我个人觉得多数业务场景下单线程事件循环加多easy handle就足够了Multi Interface的编程模型复杂度高了不少。7.6 用curl命令验证服务器行为在实际排查时我会先用curl命令行工具复现问题再回到代码里找原因。命令行和库共享同一套内核所以基本能用命令行复现的用代码也一样curl -v -X POST https://example.com/api/v1/login \ -H Content-Type: application/json \ -d {name:test,password:123}-v输出详细日志会显示TLS握手过程、请求头、响应头。这个习惯能帮你把问题快速二分是HTTP协议层的问题还是代码逻辑的问题。8. 我个人在实际操作中的体会我最初学libcurl时没觉得这个库有多大难度真正用起来才发现门槛全在细节和边界情况里。回调函数的缓冲区管理、超时和重试策略、SSL证书的信任链处理、多线程下的全局初始化这些东西看起来都是小问题但任何一个没处理好线上就是事故。如果你准备把libcurl集成到自己项目里我的建议是先花半小时读一下docs/libcurl/目录下的curl_easy_setopt.3和curl_easy_perform.3把数据流和控制流搞清楚再动笔写代码。写的时候把上一节的几个坑对照一遍基本能避免80%的崩溃问题。还有一个小技巧分享给你调试的时候在代码里加上CURLOPT_VERBOSE, 1Llibcurl会把完整的请求和响应信息输出到stderr包括TLS握手的细节。我的经验里很多问题其实在这一步就能看明白比如某个请求头没发出去、重定向地址不对、证书链缺失全都有迹可循。调试完再把这个选项关掉就行。从使用到制作从demo到线上稳定运行libcurl的每个阶段都有值得深挖的细节。希望这篇内容能帮你把网络传输这层地基打得更稳一些。
返回列表