Linux Device Driver Development for Standalone Cheque Scanner Machine - Internship Project at Zone24x7 (Pvt) Ltd

Lahiru Gunathilake 2026

Theme image

In 2019, as part of the industrial training program of my degree in the Department of Computer Science and Engineering at the University of Moratuwa, where I had specialized in embedded systems engineering, I was selected as a Trainee Associate Electronics Engineer in the Embedded Systems Division at Zone24x7 (Pvt) Ltd for the university's six-month internship program. Looking back seven years later, it remains one of the better decisions I've made in my career: not just for the technical ground I covered, but for the people I got to learn from.

My original notes are no longer with me, so I'm excitedly putting this together from memory while I still can! Better to document it now rather than let my memories about the project fade into the void forever! 🫠

Before anyone was assigned to a teams, all interns in the division went through a formal induction built around a small firmware development exercise on an STM32 development board. The intent, as best I understood it at the time, was less about the specific peripheral exercises and more about calibrating everyone to the same baseline, working with C language, register-level thinking, toolchain familiarity, and the discipline of reading a datasheet before writing a line of code. It was a sensible on-ramp before being let loose on production codebases.

Once the induction was complete, interns were assigned to projects based on their preference. I chose device driver development for a standalone cheque scanner machine, built around a Xilinx Zynq-7000 series SoC. The Zynq's dual architecture, an ARM Cortex-A9-based Processing System (PS) coupled with FPGA programmable logic (PL) on the same die, gave the project both a Linux-facing software side and a hardware-facing FPGA side, which was exactly the kind of full-stack embedded exposure I was looking for. However, I worked only on device driver development.

I worked alongside three teammates (I would rather say they are an elite team in electronics 😉), whose guidance shaped how I think about embedded software to this day. It's worth noting this was 2019 — pre-generative AI era. My references were numerous datasheets, tutorials, Stack Overflow and a small number of YouTube channels (Specially BOPV) that covered embedded Linux and Yocto in real depth.

System architecture

System block diagram

Modular architecture of the system

At a high level, the system was in a moduler layeres as follows:

Each hardware peripheral was isolated behind its own driver and controller pair, and the C++ API gave the rest of the system a stable interface regardless of what was happening underneath at the driver level. All driver development on this project was done exclusively with character device drivers since every peripheral was fundamentally a stream of discrete reads, writes, and control operations.

My responsibilities

My work on the project fell into a few distinct threads:

Drivers implemented

Status LED driver

The machine had a single status LED, and the job was to communicate machine state to the operator entirely through blink patterns on that one LED, a classic embedded constraint of doing a lot with very little UI. We defined a set of machine states and implemented distinguishable blink patterns for each. The current state was exposed to userspace through an ioctl() interface; internally, the driver drove the LED by direct GPIO access.

Track sensor driver

The scanner used two track sensors to determine the position of a cheque bill as it moved through the transport path. Each track sensor was a simple IR emitter/detector pair: an IR LED and a phototransistor (or similar receiver) positioned opposite each other, with the cheque passing between them. When the cheque interrupted the beam, the sensor's output went low, giving a discrete, edge-triggered signal that told the system a cheque had reached that point in the transport path. Sensor state and events were communicated to userspace via ioctl(), the same pattern used across the other drivers.

Magnetic stripe reader driver

This driver handled reads from the magnetic stripe on bank cards. When a card's stripe passed through the reader's slot, the driver captured the encoded track data into a buffer and passed it up to the userspace controller layer. For anyone unfamiliar with the underlying format — and worth restating here since it's genuinely relevant to how this driver had to be structured — magnetic stripe cards conforming to the ISO/IEC 7811 and ISO/IEC 7813 family of standards carry up to three tracks:

Track Origin Recording density charactor encoding Max content
Track 1 IATA 210bpi 7-bit alphanumeric Maximum content
Track 2 ABA 75bpi 5-bit numeric Up to 40 characters — PAN, expiration date, service code, discretionary data
Track 3 Thrift/ISO 4909 210bpi 5-bit numeric Up to 107 characters — read-write track, mostly deprecated in modern financial cards

Modern financial-transaction card readers typically only need Track 1 and/or Track 2 — dual-track readers are common precisely so the account data can be cross-validated between tracks, or so the system can fall back to the lower-density Track 2 if Track 1 is damaged or unreadable. Track 3 was historically read-write and used in some transit and access-control applications, but it's essentially obsolete in banking contexts today. The test card given to me was with only 2 tracks as I can recall.

Image sensor driver

The image sensor's job was to capture a colour image of the cheque bill as it passed through the scanner. The broader scan sequence — motor control timed against the track sensor signals to move the cheque through the optical path — was implemented by other members of the team; my scope was the image sensor driver itself: basic image capture functionality and forwarding the captured image data to the upper layers. The system supported three image quality settings, and, from what I recall, resolution and scan speed were selected jointly based on which quality level was chosen — a fairly standard trade-off in line-scan or flatbed-style document imaging, where higher resolution capture at a fixed sensor readout rate forces either a slower transport speed or a reduction in captured detail.

FPGA driver

The final driver interfaced with the FPGA fabric on the Zynq's programmable logic side. My task here was narrower than the others: reading and writing to a defined set of registers via ioctl(), most likely memory-mapped registers exposed by custom logic in the PL and accessed from the PS side through the AXI interconnect, in the typical Zynq PS-PL register-access pattern.

Best practices I picked up

Beyond the general best practices you'd expect on any serious C codebase, three specific conventions from this project have stayed with me:

Single point of return

Rather than returning from multiple points inside a function, declare the return value as a variable at the top of the function, assign to it at the relevant points in the control flow, and return it once at the end. This makes it much easier to reason about what a function can return and simplifies adding cleanup logic later without hunting for every exit point.

int function(int param){
    int retval = ERROR;
    ...
    if(some condition) {
        ...
        retval = SUCCESS;
    }
    if(some other condition) {
        ...
        retval = SOME_SPECIFIC_ERROR;
    }
    ...
    return retval;
}
            

Yoda conditions for comparisons

Writing comparisons with the constant on the left, if (CONSTANT == variable) rather than if (variable == CONSTANT), so that an accidental single = becomes a compile-time error (assignment to a constant) instead of a silent logic bug that only shows up at runtime, often after an unpleasant debugging session hours may be days.

Mock while loops

do { ... } while (0) as a single-exit control structure. In functions with complex, multi-stage error handling, wrapping the body in a do { ... } while (0) loop that's never actually intended to iterate lets you break out at any failure point to a single cleanup/return location, in the same spirit as the single-return-point convention above. It's a common idiom in kernel and embedded C for exactly this reason — it avoids deeply nested if/else chains while still giving you one clean exit path.

int example_function(void)
{
    int ret = 0;

    do {
        if (step_one() != 0) {
            ret = -EINVAL;
            break;
        }

        if (step_two() != 0) {
            ret = -EIO;
            break;
        }

        ret = step_three();
    } while (0);

    return ret;
}
            

Closing thoughts

Six months in the project gave me something a classroom project structurally can't: exposure to how embedded software actually gets built when there's real hardware, a real team, and real review processes involved — from bringing up a Yocto build from nothing, through writing kernel-space drivers against physical sensors, to the small disciplined habits that make C code maintainable at scale. It's also, not incidentally, where I first got a real feel for the Zynq architecture and the PS–PL boundary, which has stuck with me well beyond this specific project.