简介:面向希望在手机或平板上编写、编译并运行 C/C++ 代码的开发者,这份工具合集把 Android 端的 C4Droid 编程环境及相关配套组件整理在一起,适合利用碎片时间练习语法、验证算法片段或做小型程序原型的初学者与移动开发爱好者。压缩包共 13 个文件,约 48.91MB,以 7 个 apk 安装包为主体,另含头文件、cpp 示例源码、说明文本、图片及一个 zip 压缩包,安装与参考资料相对齐备。目前已有 724 人学习下载。C4Droid 默认采用 Tiny C Compiler,可快速完成代码编译与运行;配合 GCC 等插件后还能调用 STL 等标准库,并进一步接触 SDL、Qt 等图形与多媒体库,用于界面、音视频或网络通信类小项目的尝试。包内的示例代码与头文件便于对照理解编译流程,说明文档和终端、截屏等辅助工具则方便在设备上调试与记录结果,帮助读者少走环境配置的弯路。
1. 在手机上跑通第一个 C++ 程序:它到底替掉了哪一段工作
地铁上想验证一个指针越界的猜想,掏出手机就能编译、能看输出——这是我重新把 c4droid 装回备用机的理由。它是一款跑在 Android 上的 C/C++ 编译环境,编辑器、编译后端和终端被塞进同一个应用里,属于典型的临时救急型手机编程工具:别指望它替代桌面 IDE,但它能让你在没有电脑的场景下,把一段 C++ 源码真正编成可执行文件并跑起来。
它最适合这几类人:通勤路上想验证算法片段的人、给人演示指针和内存布局的人、写小工具时想快速跑通一段纯计算逻辑的人。不适合的人也很明确:要跑大型 CMake 工程、要依赖最新标准库特性、要在多线程里调死锁的人。它的能力上限由编译后端决定,而这个后端是可以换的,选错了会一直在莫名其妙的报错里打转。
2. 编译后端选型:GCC 插件与 TCC 差在哪一条线上
同一个应用里塞两套编译器,不是为了炫技,是因为两种场景的诉求完全相反:一种要“快出结果”,一种要“能编 C++”。装完之后第一件事不是写代码,而是搞清楚当前挂的是哪条后端,否则你会拿 C++ 源码去喂一个只认 C 的编译器,然后对着一屏看不懂的报错怀疑人生。
2.1 两条后端的能力对照与选型判断
| 维度 | GCC 插件后端 | TCC 后端 |
|---|---|---|
| 语言支持 | C 与 C++,STL 容器大部分可用 | 基本只认 C,C++ 支持非常有限 |
| 标准覆盖 | 取决于插件包,C++11 多数特性可用 | C99 为主 |
| 编译速度 | 慢,几百行也要等几秒到十几秒 | 快,几乎按下就跑完 |
| 产物形态 | 生成独立可执行文件,可导出 | 偏向即时执行 |
| 第三方库 | 可链接 SDL、SDL2、Qt 等插件库 | 基本只能纯计算 |
| 报错质量 | 信息完整但很长,需要会筛 | 简略,定位能力差 |
选型判断就一句话:写 C++、要用 STL、要链接图形库,一律走 GCC 插件后端;只是临时算个数、验证一个循环边界、跑个位运算,用 TCC 更快。我一般的做法是两个都留着,日常小验证走 TCC,正经写点东西切回 GCC,切换成本比想象中低。
2.2 从 .cpp 到能运行:三个阶段各自落在手机的哪个位置
桌面上的编译链路大家都背得出来,但搬到手机上,每一段的落点都变了。预处理阶段要找头文件,头文件的搜索根目录不再是/usr/include,而是插件包解压出来的include目录,所以你自己写的头文件如果不加-I,一定找不到。编译阶段把源码转成目标文件,这一步的产物在应用私有目录里,你平时看不见。链接阶段去找库,-l后面跟的库名要到插件包的lib目录下去匹配,路径不对就是一句cannot find -lSDL2。
真正需要记的是路径基准这件事。相对路径不是你想象中相对于源文件,而是相对于应用启动时的工作目录;这一点在单文件工程里感觉不到,一旦分了src/和include/两个目录,就会立刻暴露。我习惯在工程根目录下放一个main.cpp做入口,其余代码放src/,头文件统一放include/,然后用-I./include把它接进来,这样路径始终以工程根为基准,换设备也不会散架。
2.3 编译选项怎么填:三行解决 80% 的问题
应用设置里会有编译选项和链接选项两栏,不同版本叫法略有差异,认准对应编译器参数的那两个输入框就行。新手最容易犯的错是把这一栏当成“随便填”,结果一个-O2把断点信息全优化没了,还以为是编译器有问题。
# 填在编译选项栏,可以空格连写成一行 -std=c++14 # 指定语言标准;后端不支持这个标准时会直接报 unrecognized -Wall -Wextra # 打开常用告警,手机上没 IDE 提示,全靠它兜底 -O2 # 优化级别;调试阶段建议换成 -O0 -g,否则调试信息会被优化掉 -I./include # 追加自己的头文件目录,路径以工程根为基准链接选项单独一栏,写法上和桌面 GCC 完全一致:
-L./lib -lSDL2 -lSDL2_image -llog -landroid # -L 指定库搜索目录;-l 后的名字按 依赖者在前、被依赖者在后 排列 # -llog / -landroid 是安卓系统库,要读系统日志或拿原生窗口时才会用到参数说明里最值得强调的是链接顺序:-lSDL2_image依赖-lSDL2,那么前者必须写在前面,顺序反了会报一堆undefined reference,而这些符号明明在库里存在——这是典型的翻车现场,我第一次遇到时把库翻了三遍。
拿到新环境时,我建议先跑一个“探针程序”,把后端的能力打印出来再动手写正事:
// probe.cpp:换后端、换设备时先跑这一支,比看设置页靠谱 #include <cstdio> int main() { printf("__cplusplus = %ld\n", (long)__cplusplus); // 201103 表示 C++11,201402 表示 C++14 printf("sizeof(void*) = %zu\n", sizeof(void*)); // 4 表示 32 位 ABI,8 表示 64 位 printf("sizeof(long) = %zu\n", sizeof(long)); // 手机 32 位上通常是 4,别按桌面的 8 写代码 #ifdef __ANDROID__ printf("compiled for android\n"); // 有这个宏,说明后端确实在按安卓目标编译 #endif return 0; }这段代码看着简单,但它能一次性回答三个问题:标准到底开到哪一档、当前是哪套 ABI、条件编译宏有没有生效。后面所有关于long和指针打印的诡异问题,都能从这里找到答案。
3. 工程目录与构建配置:让代码从一个文件长成一个工程
单文件玩两天就会遇到瓶颈:函数越来越多,头文件互相引用,改一行要全量重编,等得人想砸手机。这时候需要的是把目录骨架和构建脚本先立起来,再往里填代码。手机上的构建系统比桌面简化得多,但该有的层次一个都不能少,少了就会在某个具体的下午卡住。
3.1 一套可以反复复用的目录骨架
我固定用下面这套结构,每个新工程直接复制,省得每次重新想放哪:
| 目录 | 用途 | 备注 |
|---|---|---|
src/ | 所有.cpp实现文件 | 一个模块一个文件,别超过 300 行 |
include/ | 自己的.h头文件 | 用-I./include引入 |
lib/ | 第三方库的.a/.so | 按架构分目录放,别混在一起 |
assets/ | 图片、字体、配置文件 | 运行期读取方式见第 4 章 |
build/ | 目标文件与可执行产物 | 加进忽略列表,别提交 |
工程根下只留三样东西:main.cpp、Makefile、一个记录编译选项的flags.txt。把选项单独存一份不是强迫症,是因为换设备时经常忘记自己上次填了什么,导致“在家能编、在公司编不过”这种玄学问题。血泪经验:flags.txt里连版本注释一起写,写上“这组选项在哪个后端上验证通过”,半年后回来还能看懂。
3.2 用 Makefile 接管构建:模板与逐行解释
应用对 Makefile 的支持程度取决于版本,有构建方式选项的版本可以直接指定,没有的就把多个源文件一起扔进编译命令。只要能用,就一定要用,因为手点编译按钮在文件超过三个之后就是纯粹的浪费。
# 放在工程根目录,文件名必须叫 Makefile,大小写敏感 CXX := g++ CXXFLAGS := -std=c++14 -Wall -Wextra -O2 -I./include LDFLAGS := -L./lib LDLIBS := -lm SRC := $(wildcard src/*.cpp) # 自动收集源文件,新增文件不用改脚本 OBJ := $(SRC:.cpp=.o) # 把 .cpp 后缀替换成 .o,得到目标文件列表 TARGET := build/app # 产物路径,统一放 build 下好清理 $(TARGET): $(OBJ) @mkdir -p build # 目录不存在时先建,否则链接会报找不到路径 $(CXX) $(OBJ) $(LDFLAGS) $(LDLIBS) -o $@ %.o: %.cpp # 模式规则:每个 .cpp 只重编自己 $(CXX) $(CXXFLAGS) -c $< -o $@ clean: rm -f $(OBJ) $(TARGET) .PHONY: clean # 声明伪目标,避免目录里真有叫 clean 的文件规则本身没什么难的,难的是两处细节。第一,@mkdir -p build前面那个@是抑制命令回显,不加也能跑,只是输出难看。第二,也是真正坑人的地方——命令行必须用 Tab 缩进,不能用空格。很多编辑器默认把 Tab 转成四个空格,粘进去之后 Make 会报missing separator,一行“missing separator”能让人查半小时。我现在的习惯是写完 Makefile 先跑一次clean,能正常执行就说明缩进没问题。
$(wildcard src/*.cpp)和$(SRC:.cpp=.o)这两行是让它“可维护”的关键:新增一个源文件不用改脚本,删掉一个也不会留下脏目标文件。这比手写一长串文件列表重要得多,因为手机上改文件名的成本比桌面上高。
3.3 多文件工程的增量化:改一个文件只编一个
有了模式规则%.o: %.cpp,构建就自动具备了增量能力:Make 会比较.cpp和.o的时间戳,只重编改动过的文件。这个机制在手机上价值翻倍,因为全量编译一个五文件的工程可能要等十几秒,而增量编译通常一秒内结束。
需要注意的边界是头文件依赖。Make 默认不跟踪#include关系,改了一个.h,相关的.cpp不会自动重编,于是你会看到“我明明改了头文件,行为怎么没变”——这不是玄学,是依赖缺失。常见做法是在clean之后全量编一次,或者手工只删掉受影响的那几个.o。我在工程里留了一条注释提醒自己:改头文件先clean,改实现随意。多花几秒钟,比对着旧行为调半天划算。
还有一条:build/目录在手机上偶尔会因为上次中断留下半截产物,导致链接时报奇怪的符号错误。遇到这种说不清的链接失败,第一动作永远是clean重来,而不是去读报错。
4. 把 SDL2 图形程序搬上手机:骨架、链接与资源读取
纯计算跑通之后,大多数人下一步就是想画点东西。这一步的跨度比想象中大:从控制台到窗口,涉及初始化顺序、事件循环、链接参数和资源路径四件事,任何一件做错的表现都是“能编译、跑起来黑屏”。黑屏是最难查的一类故障,因为编译器什么都不说。
4.1 一个最小可运行的窗口骨架
// sdl_min.cpp:最小窗口 + 事件循环,用来验证链接是否真的通了 #include <SDL2/SDL.h> #include <cstdio> int main(int argc, char** argv) { if (SDL_Init(SDL_INIT_VIDEO) != 0) { // 只初始化视频子系统,启动更快 printf("SDL_Init failed: %s\n", SDL_GetError()); return 1; } SDL_Window* win = SDL_CreateWindow("probe", 0, 0, 640, 480, SDL_WINDOW_SHOWN); // 竖屏下会被拉伸,调试阶段先别纠结比例 if (!win) { printf("CreateWindow failed: %s\n", SDL_GetError()); SDL_Quit(); return 1; } SDL_Renderer* ren = SDL_CreateRenderer(win, -1, 0); bool running = true; SDL_Event e; while (running) { while (SDL_PollEvent(&e)) { // 不抽事件会被系统判定无响应 if (e.type == SDL_QUIT) running = false; if (e.type == SDL_FINGERDOWN) running = false; // 手机上没关窗按钮,点一下退出 } SDL_SetRenderDrawColor(ren, 20, 20, 24, 255); SDL_RenderClear(ren); SDL_RenderPresent(ren); SDL_Delay(16); // 约 60fps,不加会满载发热 } SDL_DestroyRenderer(ren); SDL_DestroyWindow(win); SDL_Quit(); return 0; }逐段解释几个关键点。SDL_Init只开SDL_INIT_VIDEO,是因为手机上初始化音频和手柄既慢又容易拿到空设备。每次创建失败都立刻打印SDL_GetError()并退出,而不是继续往下跑——图形库在对象为空时继续调用,行为是未定义的,后面会直接闪退,连日志都留不下。事件循环里必须处理SDL_FINGERDOWN,原因很现实:手机上没有标题栏和关闭按钮,不给一个退出通道,你只能杀进程。最后一行的SDL_Delay(16)是给渲染循环踩刹车,去掉之后 CPU 会一直满频跑,几分钟后设备发热降频,帧率反而更差。
4.2 链接参数与库路径:undefined reference 的四种来源
同一个骨架,编译过了但链接失败的场景非常集中,基本逃不出四种:库名写错(-lSDL而不是-lSDL2)、顺序颠倒(被依赖的写在前面)、-L路径相对基准不对、库的架构和当前 ABI 不匹配。前三种看报错里的符号名就能猜出来,第四种最阴。
架构不匹配的典型表现是:链接阶段报file format not recognized或者一大堆莫名奇妙的undefined reference,而这些符号在库里明明存在。原因是你拿了一个 64 位的.so去链 32 位的目标文件。解决办法不是改编译选项硬凑,而是把lib/按架构分目录,比如lib/arm64/和lib/armv7/,构建时按当前设备选。判断当前 ABI 最直接的办法还是第 2 章那段探针程序,sizeof(void*)打出来是 4 还是 8 一目了然。
我在.so上还吃过一次亏:某些库要求运行时也能找到同目录的依赖库,光链接通过没用,跑起来还是提示加载失败。这种时候要检查的是运行期库搜索路径,而不只是-L。
4.3 资源文件读取:fopen 返回 NULL 的真实原因
最容易被误判成“权限问题”的其实是工作目录问题。同一条fopen("assets/font.ttf", "rb"),在桌面上跑得好好的,到手机上必然返回空指针——因为进程的当前工作目录不是你以为的工程根目录。这不是权限,是路径基准。
排查顺序我固定这么走:先把fopen的返回值当断言用,为空就打印实际尝试的路径和errno;再用绝对路径试一次,确认文件真的在那里;确认后把相对路径改成基于一个统一的根目录常量拼接。常见做法是在入口处定义一个资源根目录宏,所有读取都走它,这样换设备只改一处。
// 资源读取的统一入口:先确认路径,再谈读取 #include <cstdio> #include <cerrno> #include <cstring> FILE* openAsset(const char* root, const char* name) { char path[512]; snprintf(path, sizeof(path), "%s/%s", root, name); // 根目录 + 相对名,避免散落的字面量 FILE* fp = fopen(path, "rb"); if (!fp) { printf("open failed: %s, errno=%d (%s)\n", path, errno, strerror(errno)); } return fp; }snprintf的第二个参数是缓冲区大小,别省;省略是典型的越界来源。errno一定要打出来,ENOENT是路径不对,EACCES才是权限问题,两者处理方式完全不同。
5. 避坑与排查:五条返工记录,每条都值得抄走
下面这些不是我编出来的清单,是真正让我重做过工程的场景。每条都按“现象 → 原因 → 解决”写,重点看原因那一栏,因为现象往往长得一模一样。
5.1 编译期:头文件与链接顺序
现象:fatal error: xxx.h: No such file or directory,但文件明明就在工程里。原因:头文件的搜索路径不包含你放它的目录,编译器只认-I追加过的路径和插件自带的系统路径。解决:加-I./include,并确认写的是相对工程根的路径。多个-I按从具体到通用排列,避免同名头文件被错误版本抢先命中。
现象:链接阶段报一屏undefined reference to ...,但符号确实在库里。原因:-l顺序不对。链接器是单遍扫描,它先看到引用、后看到定义才解得开,反过来就解不开。解决:把依赖别人的库写在前面,被依赖的写在后面;实在理不清就先把所有-l列出来按错误提示里的符号名逐个挪位置。
5.2 运行期:闪退、黑屏与无响应
现象:编译零报错,点运行直接闪退,日志只有一行Segmentation fault。原因:九成是空指针或越界。手机上没有调试器给你单步,但可以用 printf 二分定位;在可疑函数入口和出口各打一行。解决:把可疑区间用 printf 切成两半,看最后打出来的是哪一行,重复三四次就能定位到具体语句。这招笨,但在没有断点的环境里最可靠。
现象:窗口出来了,全黑,什么都不画。原因:常见的有两种——只在初始化时清屏一次而没进循环,或者SDL_RenderPresent被漏掉了,绘制全在内存里没提交。解决:确认Clear和Present都在循环体内,先画一个纯色矩形验证管线通不通,再往上叠业务逻辑。
现象:界面卡住,系统弹出“无响应”。原因:事件循环里没有抽事件,或者某次计算耗时太长把主循环堵死了。解决:SDL_PollEvent必须每帧调用一次;耗时计算拆成小块,或者降到不阻塞绘制的位置去做。
5.3 输入与环境:中文注释、换行符与输入法
现象:加了中文注释之后编译报奇怪的字符错误。原因:文件编码和编译器期望的不一致,某些后端对非 ASCII 源码比较挑剔。解决:源文件统一存成 UTF-8;如果后端仍然不认,就把注释改成英文,把中文放到单独的说明文件里。这不是妥协,是省时间。
现象:代码在电脑上编过,粘到手机上第一行就报错。原因:换行符从 CRLF 变成了 CRLF 而编译器只认 LF,或者反过来;也可能是粘贴过程把全角空格带了进来。解决:编辑器里开“显示不可见字符”,肉眼确认缩进里没有全角空格。这个坑我踩过不止一次,报错信息完全不提示,只说“此处应有分号”。
现象:输入#或者括号时输入法自动替换成全角字符。原因:中文输入法在代码编辑器里没切到英文标点。解决:写代码时锁英文输入,或者写完用查找替换扫一遍全角符号。后者更省心。
6. 进阶用法:把手机编出来的产物拉回电脑做交叉验证
手机编程真正有价值的地方,不在于“随时随地写代码”,而在于它天然是一个不同 ABI 的编译环境。桌面上一路long当 8 字节用,手机 32 位 ABI 上是 4 字节,这种差异在桌面环境里几乎不可能被触发,却是移动端最经典的错源。我现在的习惯是:一段涉及类型宽度、指针打印、文件偏移的代码,写完先在手机上编一遍跑一遍,再拉回电脑对比行为。
# 电脑侧:把工程推到设备可见目录,触发一次编译,再取回产物 adb push ./src /sdcard/c4proj/src adb pull /sdcard/c4proj/build/app ./app_arm # 取回手机编出来的可执行文件 adb logcat -s DEBUG:V AndroidRuntime:E # 抓自己的输出和崩溃栈,tag 按实际替换adb push那一步要注意的是目标目录必须是设备上真实存在且可写的,不存在就先adb shell mkdir -p。logcat的-s是过滤器,DEBUG:V表示只放行自己打的 tag,AndroidRuntime:E单独保留崩溃栈——这两个一起开,既不会被系统日志刷屏,又不会漏掉崩溃原因。这套回环的价值在于:同一个源文件,两套 ABI,行为不一致的地方就是隐藏最深的那批 bug。
| 常见差异点 | 手机 32 位 ABI | 桌面 64 位 | 处理方式 |
|---|---|---|---|
sizeof(long) | 4 | 8 | 打印用%ld,但别假设宽度 |
sizeof(void*) | 4 | 8 | 指针转整数用uintptr_t |
| 文件偏移类型 | 视实现而定 | 视实现而定 | 统一用固定宽度类型 |
| 结构体对齐 | 更紧凑 | 更宽松 | 序列化时显式指定宽度 |
表格里最后一行是最容易吃亏的:直接把结构体二进制写文件,在两套 ABI 之间来回读,字段就全错位了。解决方式不复杂,序列化时逐个字段按固定宽度写,别整块 memcpy。
从那以后,我每次在手机上验证涉及类型宽度的代码,都会强制走一遍“探针程序 → 手机运行 → 拉回电脑对比”这条链路,哪怕只改了一行。多花两分钟,省下的是后面几小时的“为什么电脑上是对的”。希望帮到你。
本文还有配套的精品资源,点击获取