You are browsing as a guest. Sign up (or log in) to start making projects!

Zinn

  • 12 Devlogs
  • 40 Total hours

A high-performance, perceptual media-to-ASCII engine written in pure C23.

Open comments for this post

2h 58m 56s logged

Devlog #11: Switching to Meson (Commits: c65e483c45, c1bdb7eb50)

4 changed files with 305 additions and 233 deletions

Switching to Meson from a Makefile was quite an easy decision. Considering I want a cross-platform build system that is fast, Meson+Ninja stood out as the best possible candidate. On top of that, Meson easily supports cross-compilation, which fits my use case even more!


Switch to Meson (Commit c65e483c45)

The first commit that this devlog details contains the initial implementation of meson.build, and of a Makefile shim.

meson.build

I decided to write meson.build as idiomatically as possible. I followed the mesonbuild.com documentation for this purpose.

Because of my project’s reliance on ISO C23, I had to make sure that Meson supports setting c23 as an option. That means ensuring Meson is above or equal to v1.4.0.

The compilation flags and linking flags stay the exact same as in my previous Makefile, and on top of that, Meson checks whether a flag is supported and prints it right out while compiling!

Makefile shim

The only drawback I encountered while toying with Meson is that the commands can get quite lengthy. For example, to compile and run my project from nothing via meson looks something like this:

meson setup build-debug --buildtype=debug -Db_ndebug=if-release && meson compile -C build-debug && ./build-debug/zinn

While via my previous Makefile it looked like this:

make debug && make run

Of course, this has a lot to do with the fact that Meson saves a bunch of metadata, but nevertheless the flow is slower when trying to remember all the meson options.

That’s why I created a Makefile shim. The term comes from Embedded Artistry, and it refers to a Makefile that acts as a front-end for Meson (in this case).

I wrapped all the meson commands in neat Makefile targets, so I can just run make debug && make run all the same while still getting the valuable Meson features.


Windows & cross-compilation support (Commit c1bdb7eb50)

With Meson now working in my codebase, it was time for cross-platform support. Adding it was really easy.

meson.build changes

The only change in meson.build besides the Windows linker-flag section is that is_windows and is_mingw detection.

cross folder & .ini file

Cross-compilation is simply compiling code for a different computer on your own computer. In my case, I wanted to compile for Windows while staying on Linux. In my old Makefile this would be a huge hassle, but Meson only needs this little file to know the target and which cross-compiler binaries to use:

[binaries]
c = 'x86_64-w64-mingw32-gcc'
ar = 'x86_64-w64-mingw32-ar'
strip = 'x86_64-w64-mingw32-strip'
windres = 'x86_64-w64-mingw32-windres'
exe_wrapper = ['wine']

...

The last row is where the magic happens: exe_wrapper tells Meson to run any produced Windows binary through Wine, so I can test the cross-compiled output directly from Linux.


What’s next?

If you checked the attachment out, you can see a bunch of compilation errors; those are there on purpose. I have yet to update my actual code to support Windows.

0
0
4
Open comments for this post

3h 27m 22s logged

Devlog #10: Unit tests for CLI argument parser (Commit: 90120d4b79)

5 changed files with 1448 additions and 11 deletions

The last core module got its tests. 73 new tests for the CLI argument parser, which makes 341 tests across 7 suites. The parser has more unit tests than any other function in any suite.


Schema(s) for the tests

To test the parser you need something to parse, so I built a test schema that covers every option type.

The main test schema has four options, each of a different type. Additionally, I made a few deliberately broken schemas for the failure paths: duplicate long names, duplicate short names, an empty schema, one with 33 options (one over the max), and one whose offset_in_config points straight out of the struct.


Small fixes uncovered along the way

Writing the tests exposed two issues worth mentioning.

ZINN_EXPECT_DOUBLE_EQ dumped its constexpr double EPSILON into the surrounding scope. As soon as you’d use it twice in one scope, you’d run into redefinition errors. I simply wrapped the macro’s contents in a do {} while(0) loop to prevent this from happening.

The default config values were magic numbers. Now they’re named constants (ZINN_CLI_DEFAULT_WIDTH, …) living in the header, and the tests assert against those instead of hardcoded values.


What the tests cover

The zinn_cli_parse tests take up most at 65. The remaining 8 are split between 4 other functions, each with one nullptr path and happy-path.

Schema validation and null checks (8 tests)

Every error path of zinn_cli_parse: null schema, null argv, null config, plus each of the broken schemas from the harness, all bail with the correct error code.

The help and version prescan (10 tests)

This section is the interesting one, because the pre-scan for --help/--version is intentionally naive: it scans for the flag before any real parsing. So zinn --str --help returns OK with help set, even though --str is missing its required value. The tests pin down ordering: --version --help gives version, --help --version gives help, since the loop exits on the first hit. zinn -- --help will toggle the help boolean and skip the rest of the parse.

The last case is a bug. In “./prog – –help” the pre-scan should identify the -- delimiter and exit the pre-scan loop without toggling the help boolean, thus letting it be parsed as a positional argument.

Option forms (6 tests)

Everything the parser accepts: --width 80, --width=80, -w 80, -w80, and mixed forms in a single invocation. Bundled values handle signs too, so -n-42 parses to -42.

-shello (short for –str hello) also works correctly:)

Positional arguments (10 tests)

Positionals fill input_path then output_path; a third one errors out. After -- the parser treats everything as a positional argument.

Fun fact, if you pass -- twice, both get consumed as the “positional mode switcher”.

Value and error handling (16 tests)

An int option given abc, overflow past INT64_MAX, 1e309 against a double, values that themselves look like flags (--num --flg), empty values (--str=, --num=), and --str=a=b, where everything after the first equals sign becomes the value.

Unknown options (3 tests)

Both flag variants are asserted to reject unknown identifiers.

Repeats and required options (12 tests)

Last occurrence wins. Required option checking happens after the full parse.


What’s next?

With the final core module’s unit tests now done, I plan to switch build systems so the engine can work on both Linux and Windows flawlessly, which is a prerequisite for the beautiful media-to-ASCII pipeline.

0
0
6
Open comments for this post

4h 46m 46s logged

Devlog #9: CLI Argument Parser (Commit: 870e5f9279)

7 changed files with 782 additions and 32 deletions

The CLI argument parser is Zinn’s final core module. It’s built entirely from scratch, without any external dependencies. Other modules (string views, error handling, logger) were designed to be used seamlessly, and this is the first proof.


The architecture

The parser is built around declarative option schemas:

const ZinnCliOption ZINN_CLI_OPTIONS[] = {
    {.long_name = ZINN_STRING_VIEW_INIT("width"),
     .short_name = 'w',
     .type = ZINN_CLI_TYPE_INT64,
     .offset_in_config = offsetof(ZinnConfig, width),
     ...},
};

Each option knows its type, whether it’s required, and exactly which byte offset inside ZinnConfig to write to. The parser walks argv, matches tokens against schema entries, and writes parsed values directly into the config struct via offsetof + pointer arithmetic.

This prevents huge bug-prone switch statements you’d have to write (or copy-paste) if using getopt, for example.

The schema validates itself at parse time: duplicate short/long names, offsets exceeding the config struct, zero options, and too many options all get caught before any argv is touched.

For the caller, using this module is rather simple. Declare the schema, call zinn_cli_parse( /*...*/ ), handle failure, and the config is provided nicely in a structure.


Following the code style

The original parsing function I wrote was a huge, monolithic function with over 200 lines. I extracted what I could:

  • _zinn_validate_schema: validates the schema itself
  • _zinn_pre_scan_help_version: early return for --help / --version before parsing errors can block them
  • _zinn_write_option_value: type-dispatching write via offsetof/switch

The main loop is still ~150 lines. The long-option and short-option matching share too many moving parts: the argv index i (which gets bumped for next-arg values), matched_index (required-flag tracking), the positional_only flag, and the token itself. Extracting them would mean threading half a dozen mutable out-parameters through a helper that’s only called once.

Technically, the parsing function breaks the code style, but it would’ve been too much trouble than its worth to try and extract anything more. (Also, I wrote the rules, so I can do whatever I want)


Safety

I own the entire error path. Every parse failure captures file, function, line, and a description in the diagnostic stack. Run --width=abc and you get a trace pointing to the exact switch case that failed, plus a Use ‘–help’ hint (see 2nd attachment). You don’t get that from getopt returning '?'.


What’s next?

After adding a new module, unit tests follow, as usual.

0
0
5
Open comments for this post

2h 54m 37s logged

Devlog #8: Unit tests for String View (Commit: 63f7ec3f88)

5 changed files with 1402 additions and 2 deletions

After building the string view module, I did what any sane person would do: wrote 110 tests for it. The new “String View” suite brings the total test count to 260 across 6 suites, and the assertion count to over 1000!!!


How the heck did you write 1402 new lines of “code” in 140 minutes?

If we exclude comments and blank lines, that’s still a lot of lines. The answer is simple: the editor I use is Neovim, which allows me to use find & replace (see first attachment), quickly copy, delete, manoeuvre lines with simple keybinds, and much more!

In the attachment you can see me writing the general structure of roughly 20 tests in one single command, thus saving me 10s of minutes. I simply then replace the test-specific data and assertions and I’m all set!


New test macro: ZINN_EXPECT_DOUBLE_EQ

Floating point equality is a lie. ZINN_EXPECT_EQ converts to uintptr_t under the hood. It being an integer type, any floating point number you convert to uintptr_t gets its floating part cut off. So I added ZINN_EXPECT_DOUBLE_EQ for checking equality of floating point numbers.


Test helpers

Two local macros keep the test file from being even longer than it already is:

  • EXPECT_ZINN_STRING_VIEW_NULLPTR(sv)
  • EXPECT_ZINN_STRING_VIEW_VALID(sv, e_data, e_len)

Each macro expands to a few lines of code, which might seem pointless at first, but for 110 tests that use the macro, it adds up.


What got tested

Every public function in the string view module was tested thoroughly: null pointers, empty inputs, boundary values, and at least one “happy” path.

Constructors and macros (8 tests)

Both constructor macros are verified. from_parts rejects null data and zero length. from_cstring rejects null and empty C strings.

Slicing and trimming (23 tests)

subview is tested with the offset past the end, offset equal to length, zero count, clamping when count overshoots, exact fits, and partial slices. Each trim variant has tests for null input, no whitespace, some whitespace, all whitespace, and mixed content with whitespace only on one side.

Comparison and matching (45 tests)

equals checks length mismatch, null data on either side, the same-pointer fast path, exact match, and content mismatch.

equals_case_insensitive goes further: same categories plus non-ASCII bytes (the lookup table should pass them through unchanged) and a mismatch on the last character to make sure the loop doesn’t stop early.

starts_with and ends_with both test null data, null prefix/suffix, empty prefix/suffix (should return true), empty source (should return false), prefix/suffix longer than the source, match, and no match.

find_char tests null data, null out pointer, empty view, char not found, and found at positions first, middle, and last.

split_first has the most edge cases: null output pointers independently, null string view, empty string view, delimiter not found, delimiter at start, middle, end, and a single character that is the delimiter.

Number parsing (35 tests)

to_int64 and to_double are the heaviest. Both test null out, null data, whitespace only, trimming working, buffer overflow, overflow/underflow via ERANGE, no digits, and partial parses like "123abc".

to_int64 additionally tests positive, negative, zero, INT64_MIN, INT64_MAX, plus sign prefix, and a lone hyphen.

to_double additionally tests basic, scientific notation, negative, plus prefix, integer-only input, nan, inf, -inf, decimal-only like ".5", and a bare dot.


What’s next?

Yet another core module. Regardless of what it is, I’m still hours away from coding some fun ASCII conversions..

0
0
4
Open comments for this post

3h 5m 27s logged

Devlog #7: String View (Commit: 656a879786)

3 changed files with 624 additions and 4 deletions

The next core module I built was the string view.

The primary goal of this module was to build it fast, avoid heap allocation, ambiguity, and make it “bulletproof”.

My reasoning behind making this module is simple: it will be heavily used in the upcoming CLI argument parser and the media-to-ascii engine.


The struct and constructors

ZinnStringView is dead simple, just a const char* data and a size_t length. Two constructors build views from either explicit parts or null-terminated C strings. Both return ZINN_STRING_VIEW_NULLPTR if you pass them null or empty data.


The literal macro

The coolest part of this module is ZINN_STRING_VIEW_LITERAL(). It wraps a string literal at compile time using _Generic to detect whether you passed an actual literal or a pointer variable.

ZinnStringView sv1 = ZINN_STRING_VIEW_LITERAL("hello");  // works
ZinnStringView sv2 = ZINN_STRING_VIEW_LITERAL(pointer);  // compile error (see attachment)

If you pass a variable, _Generic sees char* or const char* and the static_assert fires with a message telling you to use zinn_string_view_from_cstring() instead.

Also, while making the attachment I realized what I wrote is actually not correct for C23, as you cannot have static assertions in expressions. This is only a feature in the upcoming C2Y (C29) standard.


Implemented functions

Generally, I preferred using standard library functions as they are battle-tested, and more often than not use SIMD instructions for much faster execution than whatever I could come up with.

Slicing and trimming

zinn_string_view_subview gives you a sub-slice with bounds clamping. If offset is past the end you get ZINN_STRING_VIEW_NULLPTR. If offset + count overshoots, it clamps down. The three trim functions share one implementation behind an enum that controls whether to trim left, right, or both.

Comparison and matching

I built a 256-byte lookup table that maps A-Z to a-z for case-insensitive comparison. It’s locale-independent, so no tolower() surprises on Turkish systems. find_char delegates to memchr, which your libc likely implements with SIMD (16-32 bytes per cycle). starts_with and ends_with are just memcmp with an offset. split_first finds the delimiter via memchr and chops the view in two.

Number parsing

Parsing from a string view is tedious because standard library functions expect null-terminated strings. The solution is to copy into a small stack buffer, null-terminate it, and call the respective function. The double variant uses strtod_l with a POSIX “C” locale so "3.14" is parsed correctly even if the user’s system uses commas as decimal separators. Both trim whitespace first, check for overflow via errno, and reject partial parses like "123abc".


What’s next?

My favorite activity, unit tests!

0
0
4
Open comments for this post

1h 52m 22s logged

Devlog #6: Unit tests for Error Handling (Commit: 252eb6d937)

5 changed files with 1312 additions and 0 deletions

After implementing the error code system, diagnostic stack, and scope guard, I needed to make sure they actually work before I start building on top of them. So, my favorite activity, writing unit tests, was awaiting me with 64 more tests across 3 new suites.


New test macro: ZINN_EXPECT_EQ_STR

The diagnostic stack tests compare message strings. ZINN_EXPECT_EQ would compare pointer addresses if you passed strings, not contents, so I added a strcmp based variant:

ZINN_EXPECT_EQ_STR(frame->message, "42 hi");

On failure it prints actual vs expected strings, which makes debugging test failures a lot easier.


Error Code (16 tests)

The tests verify that each field lands in the correct position and that overflows are safely masked out:

  • “Round-trip”: pack ZINN_DOMAIN_LOGGER | ZINN_SEVERITY_INFO | 0x42, then extract each field and verify
  • Overflow tests: the masks (0xFFFFF, 0xFF, 0xF) clip overflowed fields back to zero, tested with values one past each bit boundary
  • Max values: 0xFF domain, 0xF severity, 0xFFFFF code all survive the round trip
  • All 6 predefined codes: decoded and checked against expected domain/severity/code down to the exact values

Diagnostic Stack (36 tests)

Raw push/pop/peek/clear

  • Push: basic field assignment, null file maps to "unknown", null function maps to "unknown", null format maps to "(none)", empty format produces empty string, printf args ("%d %s", 42, "hi") produce "42 hi", truncation at exactly 127 bytes + null terminator at 128
  • Push overflow: fill all 16 slots, the 17th push returns false, depth stays pinned at 16, the last valid frame is untouched
  • Pop: once from a non-empty stack decrements depth, pop on an empty stack is a no-op, pop 3 of 5 leaves depth at 2, pop all 16 resets to 0
  • Peek: empty stack returns nullptr, non-empty returns the top frame (LIFO: second push overwrites the peek target), after clear returns nullptr
  • Clear: empty stack stays at 0, stack with 5 frames resets to 0

Convenience macros

Each macro has its own test helper and multiple assertions:

  • ZINN_DIAGNOSTICS_PUSH: verifies __FILE__, __func__, __LINE__ are injected, with and without format args
  • ZINN_RETURN_IF_ERR: passing ZINN_OK skips the frame and returns OK; passing an error pushes a frame containing "Propagated error" and returns the error
  • ZINN_RETURN_IF: condition true: pushes "Condition failed (true)" and returns the error; false: no frame, returns OK.
  • ZINN_TRY: wraps a successful call (forwards the value), wraps a failing call (pushes "TRY macro captured failure", returns the error)
  • ZINN_CHECK_ERROR: the only macro that doesn’t return. After pushing a frame, execution continues. Verified with a continued flag, format args, and a static message

Integration

Two isolation tests: one interleaves push/pop/push and verifies frame independence (frame 0 still holds "0" after pops, frame 1 holds "new"). The other pushes two frames with different error codes and verifies LIFO ordering and the peek return value at each step.


Scope Guard (12 tests)

Scope guards fire implicitly when a scope exits. Tested with global counters for basic fire, multiple guards, early return, null-safety, nested scopes, named guards, disarm, double-disarm, and direct execute() calls with null/disarmed/null-cleanup guards.


What’s next?

With 160 tests across 5 suites, I can take a short break from writing unit tests. Next up is the CLI argument parser (probably) the final core module before I can get to the fun ASCII stuff.

0
0
7
Open comments for this post

3h 53m 22s logged

Devlog #5: Error Handling Overhaul (Commits: 52515268fe-91402e3215)

18 changed files with 1153 additions and 547 deletions

I wasn’t happy with the old error handling. A flat enum doesn’t tell you what went wrong or how bad it is. So I tore it out and replaced it. I’m proud of this module because it uses modern C23 features everywhere you look, is fast, secure, and gives me the important debugging info in case something goes wrong.


Thread-safe error and diagnostic module (52515268fe)

This commit contains the initial implementation of the module, although with a few bugs I addressed in one of the two later commits.

Warning! This commit DOES NOT compile. I haven’t refactored the codebase to use the new error handling implemented in this commit.

Bit-packed error codes

The new zinn_err_t is a uint32_t with three fields packed into it:

[8 bits domain | 4 bits severity | 20 bits code]
  • Domain identifies the module.
  • Severity says how bad it is.
  • Code is the specific error number.

Diagnostic stack

A thread_local fixed-depth LIFO stack (max 16 frames, 128 message chars each). Every frame records file, function, line, the packed error code, a human-readable message, and a unix timestamp.

Pushing a frame manually is tedious, so I wrote macros that inject them for you.

Higher-level macros build on this:

  • ZINN_RETURN_IF(condition, error): if true, push a frame and return the error
  • ZINN_RETURN_IF_ERR(expression): if the expression fails, push and return
  • ZINN_TRY(expression): same, but evaluates to the expression’s value on success
  • ZINN_CHECK_ERROR(call, message, ...): push a frame if error, but keep going

zinn_diagnostics_dump_frames() dumps the whole stack to stderr with colored formatting, newest first.

Check the attachment to see the stack dump in a terminal. Contains all the info you’d ever need.

Scope guard

C doesn’t have RAII. But GCC has __attribute__((cleanup)). The scope guard module wraps it:

ZINN_DEFER_CLEANUP(function, pointer)

This declares a local guard that calls function(pointer) when the scope exits, whether by normal return or early return. You can disarm it with ZINN_SCOPE_GUARD_DISARM(name) if you transfer ownership.

The logger now uses this for mutex cleanup.

The old code scattered manual pthread_mutex_unlock() calls across every return path. One missed unlock would deadlock the whole process.


Refactoring the codebase (de25c634a0, 91402e3215)

The codebase was refactored to use the new error handling and diagnostic functions, macros and types.

A few examples:

  • Logger: returns zinn_err_t everywhere; precondition checks use ZINN_RETURN_IF; mutex handling uses scope guards
  • Arena: zinn_arena_create changed from returning a pointer to returning an error code with an output parameter. Every error site now pushes a diagnostic frame with details
  • Tests: adapted to the new API. Fixed several real bugs along the way: missing fclose() calls that leaked file descriptors and a renamed internal function amongst a few others

The test count also dropped from 50 to 49 Logger tests because I removed a redundant test.


What’s next?

With the error handler now functional, it will be finally time for the (probably) final core mod-… oh wait. Nevermind that. Next I have to write a ton of unit tests for this new module. Lucky me!

0
0
3
Open comments for this post

7h 8m 38s logged

Devlog #4: Arena Allocator & Test Framework improvements (Commits: bd81ab254f-ba67b8cade)

8 changed files with 1638 additions and 44 deletions

This devlog covers another core module, the Arena Allocator, as well as improvements to the test framework. Implementing this took quite some time, due to the fact that I want to make sure the codebase stays secure and fast.


Arena Allocator (bd81ab254f)

The next core module I implemented is a linear arena allocator, the polar opposite of a general-purpose malloc. The whole purpose is simple: no free individual allocations. Just bump a pointer and reset the whole thing when you’re done. It’s also quite fast.

Alignment without compromise

Every pointer returned by the arena is aligned to max_align_t (typically 16 bytes on x86-64).

When the CPU loads an 8-byte value from an aligned address, the memory controller fetches a single 64-byte cache line in one transaction. The bytes land in the CPU’s load buffer in order, and execution continues the next cycle.

When the same load is unaligned (say, address 0x1003 instead of 0x1000), the 8 bytes straddle two cache lines: 0x1000 and 0x1040. The memory controller now issues two separate cache line fills, the load buffer stitches the result together from the two halves, and the pipeline stalls waiting for the second request to arrive. On modern x86-64 this penalty is roughly 2-3x the latency of an aligned load.

For an allocator focused on speed, every allocation must be aligned. The arena guarantees this in two branchless instructions:

uintptr_t raw = (uintptr_t)arena->memory + arena->offset;
uintptr_t aligned = (raw + 15) & ~15;

Snapshots

The snapshot API (zinn_arena_snapshot_begin / zinn_arena_snapshot_end) lets you checkpoint the arena offset and roll back to it later. In debug builds the rolled-back region is poisoned with 0xAA so dangling reads are immediately obvious.


Unit tests for the Arena (966c4c289e)

47 thorough tests covering just about everything about the arena.


Test Framework improvements

High-precision execution timing (efd7239d47)

Every test function’s runtime is now measured. The runtime is printed next to the PASS/FAIL label with microsecond precision.

Check out the second attachment.

This commit also fixes a subtle C23 compatibility issue: nullptr has type nullptr_t under C23, which can’t be directly cast to uintptr_t. I added a _Generic dispatching macro (ZINN_TO_PTR) that maps nullptr_t to (void*)0 while leaving everything else untouched.

The failure messages also got an upgrade: actual and expected values are now printed numerically instead of a bare expression string.

Test run summary (ba67b8cade)

The original test runner supported one suite at a time. You’d call zinn_test_summary() and get a single block. Now the architecture is multi-suite.

The final output is a detailed breakdown:

  • Per-suite: runtime, tests run/passed/failed, assertions made
  • Global: success rate percentage, throughput (tests/second), average latency per test, slowest suite with its share of total time

Check out the first attachment if you are curious how it looks.


What’s next?

With memory management settled and testing infrastructure solid, the next milestone is an error handler.

0
0
1
Open comments for this post

4h 34m 1s logged

Devlog #3: Test Suite (Commit: d1e7b17ad9)

8 changed files with 1264 additions and 30 deletions

The next priority wasn’t to build more features, but to ensure that the ones I have are unbreakable. Thus, I’ve implemented a custom, zero-dependency Unit Testing Framework and a comprehensive suite of 50 tests for the Logger module.


Why Build a Custom Test Suite?

Because of my ego in the way, I didn’t want to rely on heavy external libraries like GoogleTest or Check (or any external library, for that matter).

The result?

  • Isolated Singleton Management: I added a “Backdoor” (_zinn_log_reset_internal) to Logger that allows the test suite to scrub the global state of the Logger between runs. This ensures that every test starts with a clean state, which prevents state-leakage bugs.
  • Assertion Precision: Using C23 macros, I built EXPECT and ASSERT checks that capture the exact file and line number of any failure.
  • Clean Output: The test runner provides colored feedback and a final summary.

I also updated the Makefile to add a test target, which compiles all source files EXCLUDING main.c (as I have a custom test_main.c), test files, and runs the tests.


The Logger Unit Tests (50 tests)

Instead of being lazy, I decided to write a test for everything imaginable for the Logger. This may seem pointless, even redundant at times, but as I say, better be safe than sorry.

1. Lifecycle Verification

Proving that Init/Shutdown/Reset cycles don’t leak memory or leave the mutex in a deadlocked state. I verified that the Logger returns to a “Zero State” after shutdown, allowing it to be safely re-initialized later.

2. Boundary Protection & Buffer Safety

This was the most critical part. I wrote a “ruler” test to verify exactly what happens when the 1024-character message buffer is exceeded. By analyzing the memory buffer line-by-line, I proved that the logic correctly truncates and appends ... at the exact 1023rd byte without overflowing the stack.

3. Null-Pointer Safety

Every public function was tested with nullptr arguments.

Where applicable, of course.

4. Logic & Metadata Verification

Using fmemopen() to capture the logger’s output in memory, I verified the exact formatting of timestamps, ANSI colors, and source-location metadata.

Ajajaj, yet another POSIX-only function. Surely this won’t backfire…


What’s Next?

Now that the Test Suite is done, and Logger unit tests are written, it’s finally time to tackle yet another core component, this time it being a custom (minimalist) memory allocator.

0
0
2
Open comments for this post

25m 13s logged

Devlog #2: Doxygen integration (Commit: 94277b5487)

7 changed files with 89 additions and 16 deletions

Doxyfile

This is a very short devlog, as it was a very short integration. I simply added a Doxyfile, which controls how the documentation will be generated. I set it according to my project, e.g. a C project, the project name, what directories it should traverse etc.

Makefile update

To make the documentation generation and opening as seamless as possible, I added a docs target to the Makefile, which checks if you have doxygen installed. If not, it throws a simple error message and doesn’t proceed. Once it’s verified that doxygen is available, it generates the documentation and opens it in your default browser via xdg-open.

Comments

While I was going through the codebase for a final check of the comments, I noticed a few functions didn’t have any Doxygen comments whatsoever (especially the static ones) and a few things were incorrectly documented. I added and fixed comments for clean doxygen documentation.

Attachment

In the attachment you can see the generated HTML documentation for a few functions located in logger.c. Notice the @note being displayed neatly with a yellow background on the website.

0
0
2
Open comments for this post

3h 28m 47s logged

Devlog #1: Logger implementation (Commit b565bfd1f7)

6 changed files with 594 additions and 9 deletions

The first core module I implemented is the Logger. I feel like I spent a huge amount of time on this, but it was well worth it, as it’s a marvel. Below you can read about the engineering of the logger.


The engineering of the Logger

This is a quick overview, for the full context you can check out the source code.

Multi-Stage Gating (Compile-Time + Runtime)

The logger uses a two-tier filtering system.

  • Compile-Time: Using C23 variadic macros and __VA_OPT__, the logger identifies the build type. In a release build, TRACE and DEBUG calls are completely stripped from the binary. They don’t just “not print”; they don’t exist in the final machine code.
  • Runtime: Even for levels that survive the preprocessor, the logger checks the internal state before doing any work. This ensures that there is no wasted time on string formatting or timestamping.

Thread-Safe Singleton Architecture

While zinn doesn’t use multithreading yet, I felt like it’s best to “future-proof” the Logger by guarding everything with pthread. This ensures, that whenever (or if at all) I add multithreading, the stream output will be clean and not a garbled mess of characters.

Runtime stream redirection

Again, this isn’t used currently, so this is another “future-proofing” measure. The logger can seamlessly start writing into any stream desired, as long as it exists. This will be used in the future, but no spoilers as to what will use it ;)

Modern C23 & POSIX Integration

  • Precision Timing: Using C23 timespec_get and localtime_r for nanosecond-precision timestamps with thread-safe formatting.
  • Compiler Safety: The logging function is marked with [[gnu::format(printf, x, y)]], allowing the compiler to catch type-mismatches in log messages at build-time.
  • Deterministic Memory: Zero malloc. All formatting happens in a fixed 1024-byte stack buffer. This also prevents nasty, difficult-to-catch memory bugs, which could be fatal in a core module such as the logger.

Screenshots explanation

1st screenshot

This screenshot perfectly demonstrates the preprocessor filtering. Notice the difference between the release and debug runs. In release, the TRACE log is vanished. In debug, the logger is fully verbose. This confirms that the compile-time filtering is working.

2nd screenshot

Showcase of multiple severities, as well as the FATAL one. Notice the Return code: 2 at the bottom. This proves that the logger successfully captured the error, flushed the stream, and triggered a clean abort() to prevent further corruption.

Ignore the makefile error, I suspect it occurs when I run make run, and the binary exits abnormally (e.g. return code != 0), it thinks it’s an error.

3rd screenshot

Compared the the previous screenshot (right above), you can see the TRACE log level appears. This didn’t show in the previous screenshot as the minimum log level was higher than TRACE, thus showing the minimum log level works.

4th screenshot

Here you can see how the code looked for the previous two screenshots. In the init function (zinn_log_init), you pass the minimum log level as the parameter, so when it was ZINN_LOG_TRACE, it logged all the severities. When it was something higher, it didn’t log TRACE, for example.

If you were to take a look at the code on the repository, you might notice that it is a bit different. That is due to this screenshot being a bit old, and I’ve went through like 2 hours of improving the Logger:)


Future plans

With the first core module done, the only correct next step is to implement another core feature.

0
0
1
Open comments for this post

1h 33m 16s logged

Devlog #0: Project setup (Commits: d377087563-7874ad3cc6)

10 changed files with 1056 additions and 0 deletions

For zinn, I decided that the foundation had to be as robust as the final output. Before writing a single line of code, I spent the first hour or so building a thorough foundation from scratch.


C Standard

For this project I have decided to use the latest C23 standard. Why? The answer is simple: modern features.

  1. nullptr: Replaces the macro NULL. It’s a real type, which prevents type-confusion bugs and makes the code’s intent explicit.
  2. constexpr: This allows us to define constants with full type safety. More importantly, its value is known at compile-time, thus the compiler can catch math errors or type mismatches before the code even runs.

And many others…


Code Style

I have outlined the code style that will be followed in this codebase. The whole file can be found here.


Directory Structure

The directory structure is simple:

.
├── bin      <-- contains executable binaries
├── build    <-- contains intermediate files
├── include  <-- contains header files, allows sub-dirs
└── src      <-- contains source files, allows sub-dirs

Makefile

I have developed a recursive Makefile that supports multiple build confiruations (Debug, Release, Sanitize, Coverage). It features:

  • Zero-Collision Objects: Isolated build directories prevent flag contamination between build configurations.
  • Static Analysis: The Makefile has targets for clang-tidy, cppcheck and GCC’s -fanalyzer.
  • Dependency Tracking: Using -MMD -MP flags to automatically generate header dependencies. This means the build system is “smart”; it only recompiles what is absolutely necessary.
  • Order-Only Directories: Using advanced Makefile logic (the pipe |) to handle folder creation. This prevents “ghost recompilation” where the binary is rebuilt just because a folder’s timestamp changed.

License

The project is licensed under GPLv3, which ensures it stays as FOSS (Free and Open Source Software) forever.


Future plans

Next, I plan to implement bulletproof core components, such as a logger and CLI argument parser.

1
0
10

Delete project?

Are you sure you want to permanently delete this project? This action cannot be undone.

All devlogs, followers, and associated data will be removed.

Followers

Loading…