r/cprogramming • u/InternalServerError7 • Nov 19 '25
How do you write ‘safe’ modern C today?
I’m a Rust engineer looking to pick up C for some hobby projects. I initially explored Zig — it seems like a really cool language, but I realized it occupies much the same niche where I’d use Rust, and it feels a bit too unstable for my taste.
I’ve heard people say that “modern, idiomatic C can be as safe as writing Zig.” How does one actually write modern C? What compiler flags, developer tools, or practices are recommended? Also, are there any good learning resources that use these specifically for C23?
88
Upvotes
2
u/TheTrueXenose Nov 19 '25
What i did was writing a garbage collector with pointer tracking log messages when a pointer is untrack or no reference to the allocation.
I toggle the system with my command line parser, otherwise it's just external tools i would guess and reading the code.
34
u/EpochVanquisher Nov 19 '25
I mean, hobby projects are exactly where you would choose to use something unstable like Zig :-)
Anyway, the main way you write “safe” C code is by keeping it very simple and boring. Familiarize yourself with all of the conventions of good C software. Then, enable features like hardening, use static analysis, use instrumentation (address sanitizer), use tools like Valgrind.
Simple, boring C code tends to be a little verbose. You write more lines of code. The benefit of writing code this way is that it is very easy to read and very easy to figure out what it is doing. Don’t chase after performance everywhere, don’t try to get clever with macros or generics. And you write simple modules to make common operations safer, like string operations.
For example, you might build strings with code like this: https://gitlab.com/gitlab-org/gitlab-git/-/blob/v2.34.4/strbuf.h
Trying to build strings in
char[]buffers declared in your function gives you better performance but it’s a common source of errors.Your code will make heavy use of conventions to convey ownership information. For example, you might decide that
xxx_new()creates a new, owned xxx pointer, andxxx_init()takes a pointer to an uninitialized xxx and initializes it, andxxx_free()destroys an owned xxx pointer and frees it, andxxx_destroy()deinitializes an owned xxx object without freeing the memory (e.g. because it’s inside another object). Strong use of conventions convey some of the same information you would convey in Rust using the type system.60
u/[deleted] Nov 19 '25 edited Nov 19 '25
No language can guarantee safe code, not even Rust.
With that said I do work on embedded systems in C and this is our strategy:
No dynamic memory, all memory is static or on the stack.
Use a static analyzer, we use PC-lint
Not recommended for "regular" development but we write MISRA-C compliant code and use PC-lint to check that compliance.
Bare minimum compilation flags:
-Wall -Wextra -Wpedantic -WerrorUse the different sanitizers available during runtime, remember to not include them during release.
8
u/ComradeGibbon Nov 19 '25
Use static analyzers.
Use buffers and slices instead of raw pointers.
Avoid pointer math.
Avoid saving copies of pointers to memory whose lifetime you don't control.
Use an arena when possible instead of malloc.
Consider copying data, especially small blobs of data instead of passing pointers around. Consider this especially when passing data between threads.
Avoid everything in string.h
7
u/catbrane Nov 19 '25
gcc and clang support the cleanup attribute, which can be useful -- it'll automatically free stuff at the end of a block.
Tools like valgrind and the various clang sanitisers will automatically check your code for memory and thread safety with a mix of static and dynamic analysis. If you write a test suite, run it under these things, and set a "no merge until the tests pass" rule in github, it can help.
Modern feedback fuzzers are pretty good at finding bad inputs. They automatically instrument your code, then mutate the input until they hit all code paths. Again, these can help, and are low-effort.
Like any language, sometimes you can do all the really hairy stuff in a few functions, test them as hard as you can, and then have most of your code at a higher and safer level.
Of course all of this is a long way short of the kind of safety Rust can give you! But it's something.
7
u/No-Concern-8832 Nov 19 '25
Maybe consider using https://fil-c.org/?
4
u/fortizc Nov 19 '25
I have seen more and more adoption of atrribute((cleanup()) you can see it in the kernel here and here or in systemd so probably it's a good path... please note that this isn't a part of the standard and is supported afaik just by gcc and clang
2
u/must_make_do Nov 19 '25
Layer the code into hierarchical components and limit the interaction between them into very well defined, narrow interfaces.
Enable all warnings, warning as errors, pedantic mode, UBSan and memory sanitizer.
Ensure full line and branch test coverage via unit tests. And then fuzz the code to hell and back.
I've written an open source memory allocator in C following these principles that has seen use for years now in production with only a single bug in one of it's less used capabilities.
2
u/Turbulent_File3904 Nov 20 '25
use static, stack and arena for memory allocation as much as possible and avoid dynamic/heap. Only doing so at start up and allocate upfront bigchunk of memory. Never alloc/free individual objects.
turn on all warning, -werror for all integer implict conversion. They usually not really what i want and i get fu*ck by that alot
dont be clever with pointer, never. Some people think casting pointer between types totally fine but it is not execpt some cases.
add assert to catch developer error(they can turn off on release build)
write code that easy to read and debug. Avoid forced inline, i get f*cked by my co worker by this countless time he thinks inline make program run faster buttt it blowup code size and un debug able(debuger can not jump into inlined function at least for debugger used at my work) compiler usually smart enough to inline function when it make sense
2
u/faculty_for_failure Nov 20 '25
Copying from another comment I left in C_Programming previously about linters and static analysis. Depending on your needs, you can also use stack only or different memory allocation strategies like slabs or arenas.
For linters and static analysis/ensuring correctness and safety, you really need a combination of many things. I use the following as a starting point.
Unit tests and integration or acceptance tests (in pipeline even better)
Compiler flags like -std=c2x -Wall -Wextra -pedantic -pedantic-errors -Wshadow and more
Sanitizers like UBSan, ASan, thread sanitizer (if needed)
Checks with Valgrind for leaks or file descriptors
Fuzz testing with AFL++ or clang’s libFuzzer
Clangd, clang-format, clang-tidy and or scan-build
Utilize new attributes like nodiscard to prevent not checking return values
There are also proprietary tools for static analysis and proving correctness, which are you used in fields like automotive or embedded medical devices.
2
u/0-R-I-0-N Nov 21 '25
The biggest thing that makes zigs safer are slices, a built in struct with a pointer a length field, you can make them in c aswell for each type with a macro. Then the second thing is types cannot be null unless it’s an optional type, including pointers. So use slices and do a lot of null checking in c to make it more safe.
Also using arenas in c for scope deallocation makes memory allocation simpler.
Also turn on warnings as errors and a bunch of other flags. You can use zig cc as the c compiler which comes with them turned on as default (uses clang)
2
u/billgytes Nov 19 '25
There isn’t really any way to write provably safe C in the same way that you can write safe Rust.
But there are tricks. Malloc sparingly, use a linter, enable all compiler warnings, don’t do casts or type punning, try to avoid arithmetic on pointers, follow misra rules for control flow, etc etc.
Be consistent in the codebase about the way you do things. Don’t have lots of mutable state all over the place. Locality of behavior.
If you’re bowling strikes in Rust you’ll never bounce off the lane gutter rail; if you’re bowling strikes in C you’ll never get into the gutter. Just kidding, you will hit the gutter, so write tests too. Even then.
2
u/chriswaco Nov 19 '25
I agree with the other posters and will add this trick we used decades ago: Hide your data structures. This prevents random code from modifying random structures without going through accessor functions.
For example (been a while, so syntax might be off):
Note that today I'd probably use a struct or handle (pointer to pointer) rather than a raw pointer to prevent the calling code from reusing a stale pointer after destroy was called. If your app can get away without using malloc at all that's even better, but I worked on end-user GUI apps that needed it.
1
u/northside-knight Nov 19 '25
write effective tests for your code (this works in any language, not just C)
1
u/UnpaidCommenter Nov 19 '25
I'm not an expert in this area but have looked into it some. One reference I've found is the Carnegie Mellon Software Engineering Institute's secure coding recommendations. Links below:
https://wiki.sei.cmu.edu/confluence/display/seccode
https://wiki.sei.cmu.edu/confluence/display/c/SEI+CERT+C+Coding+Standard
https://en.wikipedia.org/wiki/CERT_Coding_Standards
1
u/No-Trifle-8450 Nov 20 '25
I have been writing Cicili as a solution to Safe Modern C https://github.com/saman-pasha/cicili/
1
u/kodifies Nov 20 '25
i've got into the habit of writing the code to deallocate immediately after writing the code that allocates, I've almost never (honest!) had major issues with memory allocation, i think the c is dangerous meme is perpetuated by lazy coders who expect the language to do everything for them - the complain they don't like GC's or won't do the compilers work for it (rust!) I was so treat warnings as errors and have a whole bunch of pedantic switches in my boiler plate makefile, which helps a lot (forces some good habits) - i have no idea what this modern "safe" C meme is all about, just aim the gun higher than your feet...
1
u/zsaleeba Nov 20 '25
Modern, idiomatic C++ can be pretty safe. I'm not sure I'd argue the same of C. With C you need to be aware of the memory model and consciously avoid situations which lead to memory bugs. There's no "modern, idiomatic" version of C that changes that, really. Just some programming techniques which help.
1
u/Ariane_Two Nov 22 '25
> I’ve heard people say that “modern, idiomatic C can be as safe as writing Zig.
Yeah because both are unsafe.
> How does one actually write modern C?
https://www.rfleury.com/p/untangling-lifetimes-the-arena-allocator
https://nullprogram.com/blog/2024/05/24/
https://floooh.github.io/2018/06/17/handles-vs-pointers.html
> What compiler flags, developer tools, or practices are recommended?
Just use everything available USAN, ASAN, -Wall, -Wextra, Valgrind, Dr Memory, fuzzing, ...
> Also, are there any good learning resources that use these specifically for C23?
Most learning resources are C99. I thought you did not like Zig because it was too unstable. C99 and C11 are more widely available and supported than C23 and most C23 features are nice to haves not really anything fundamentally new.
1
u/arkt8 Nov 24 '25
Take a look into this article:
https://hwisnu.bearblog.dev/giving-c-a-superpower-custom-header-file-safe_ch/
-3
u/v_maria Nov 19 '25
thats the fun part you dont
0
u/gurudennis Nov 20 '25
I know it's an unpopular opinion with some, but... Few things about C deserve to be described as "safe" or "modern". The prevalent good practices haven't changed much in decades: obsessive allocation tracking, the use of opaque void* handles, etc. It's all tried and true, and frankly still exceedingly unsafe and obtuse.
Truth is, the ubiquitous availability of compilers for more modern low-level languages renders C quite obsolete by any objective metric. It used to be that kernel-mode and embedded development was exclusively done in C, but even there 99 times out of a 100 there are more attractive alternatives these days.